Compare commits
38
Commits
047b99e2aa
..
ai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5091cd005e | ||
|
|
82aa8948cf | ||
|
|
84a3a9a5b5 | ||
|
|
b260065245 | ||
|
|
264316eb29 | ||
|
|
c625c80958 | ||
|
|
6a04d29eed | ||
|
|
10e28cc5e2 | ||
|
|
970678986a | ||
|
|
40e45f3a01 | ||
|
|
518b79bec6 | ||
|
|
01fb4e308a | ||
|
|
b6300ffa30 | ||
|
|
b163a07e84 | ||
|
|
157f03b655 | ||
|
|
42a3a53314 | ||
|
|
9b7ca3d692 | ||
|
|
e51a59a829 | ||
|
|
4a06fa82eb | ||
|
|
80238bb7a1 | ||
|
|
dcbd46531e | ||
|
|
0864b39336 | ||
|
|
2a0c96bb4c | ||
|
|
c53213a357 | ||
|
|
62cadbcfa0 | ||
|
|
d7aefc4110 | ||
|
|
e972a8dddb | ||
|
|
dc633783e0 | ||
|
|
e33ab71fac | ||
|
|
88b9375a4e | ||
|
|
f7a3e3f0f8 | ||
|
|
72df3dec3b | ||
|
|
b80ec5c6ac | ||
|
|
fbd3aff6d8 | ||
|
|
0ad79c9bc1 | ||
|
|
e175d28c2c | ||
|
|
8e3f09774a | ||
|
|
dbc54032f7 |
Generated
+87
-25
@@ -67,7 +67,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -635,6 +635,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -704,7 +705,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"shlex",
|
"shlex",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -989,7 +990,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1000,7 +1001,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1050,7 +1051,7 @@ dependencies = [
|
|||||||
"dsl_auto_type",
|
"dsl_auto_type",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1070,7 +1071,7 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
|
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1092,7 +1093,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1118,7 +1119,7 @@ dependencies = [
|
|||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1287,7 +1288,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1812,6 +1813,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.0",
|
"hashbrown 0.16.0",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2267,7 +2270,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2359,7 +2362,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
@@ -2894,7 +2921,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3082,6 +3109,16 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.106"
|
version = "2.0.106"
|
||||||
@@ -3110,7 +3147,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3152,7 +3189,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3163,7 +3200,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3259,7 +3296,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3409,7 +3446,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3514,6 +3551,31 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
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]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.18.1"
|
version = "1.18.1"
|
||||||
@@ -3615,7 +3677,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3650,7 +3712,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
"wasm-bindgen-backend",
|
"wasm-bindgen-backend",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
@@ -3727,7 +3789,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3738,7 +3800,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3965,7 +4027,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3986,7 +4048,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4006,7 +4068,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4046,7 +4108,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ base64 = "0.21"
|
|||||||
quick-xml = "0.32"
|
quick-xml = "0.32"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
|
once_cell = "1.19"
|
||||||
|
utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,7 @@ COPY migrations ./migrations
|
|||||||
COPY tests ./tests
|
COPY tests ./tests
|
||||||
COPY diesel.toml ./
|
COPY diesel.toml ./
|
||||||
|
|
||||||
RUN cargo build --release --bin backend --bin worker --bin webdav
|
RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin
|
||||||
RUN cargo install diesel_cli --no-default-features --features postgres
|
RUN cargo install diesel_cli --no-default-features --features postgres
|
||||||
|
|
||||||
FROM debian:trixie-slim AS runtime
|
FROM debian:trixie-slim AS runtime
|
||||||
@@ -53,6 +53,7 @@ RUN apt-get update \
|
|||||||
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
|
||||||
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
|
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
|
||||||
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
|
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
|
||||||
|
COPY --from=builder /app/target/release/admin /usr/local/bin/papercrate-admin
|
||||||
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
|
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
COPY diesel.toml ./
|
COPY diesel.toml ./
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
use argon2::{
|
|
||||||
password_hash::{PasswordHasher, SaltString},
|
|
||||||
Argon2,
|
|
||||||
};
|
|
||||||
use rand::thread_rng;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let password = env::args()
|
|
||||||
.nth(1)
|
|
||||||
.expect("Usage: cargo run --example hash_password <password>");
|
|
||||||
let salt = SaltString::generate(&mut thread_rng());
|
|
||||||
let argon2 = Argon2::default();
|
|
||||||
let hash = argon2
|
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.expect("hashing failed")
|
|
||||||
.to_string();
|
|
||||||
println!("{}", hash);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_document_tags_tag;
|
|
||||||
DROP TABLE IF EXISTS document_tags;
|
|
||||||
DROP INDEX IF EXISTS idx_document_versions_document;
|
|
||||||
DROP TABLE IF EXISTS document_versions;
|
|
||||||
DROP INDEX IF EXISTS idx_documents_deleted_at;
|
|
||||||
DROP INDEX IF EXISTS idx_documents_folder;
|
|
||||||
DROP TABLE IF EXISTS documents;
|
|
||||||
DROP INDEX IF EXISTS idx_folders_parent;
|
|
||||||
DROP TABLE IF EXISTS folders;
|
|
||||||
DROP TABLE IF EXISTS tags;
|
|
||||||
DROP TABLE IF EXISTS users;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
username VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
role VARCHAR(16) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE folders (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
|
||||||
path_cache VARCHAR(1000),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
|
||||||
|
|
||||||
CREATE TABLE documents (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) NOT NULL,
|
|
||||||
original_name VARCHAR(255) NOT NULL,
|
|
||||||
content_type VARCHAR(100),
|
|
||||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
|
||||||
current_version INTEGER NOT NULL,
|
|
||||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted_at TIMESTAMPTZ,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder ON documents(folder_id);
|
|
||||||
CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
|
|
||||||
|
|
||||||
CREATE TABLE document_versions (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
version_number INTEGER NOT NULL,
|
|
||||||
s3_key VARCHAR(500) NOT NULL,
|
|
||||||
size_bytes BIGINT NOT NULL,
|
|
||||||
checksum VARCHAR(64) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
|
||||||
|
|
||||||
CREATE TABLE tags (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
label VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
color VARCHAR(7),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE document_tags (
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
||||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
assigned_by UUID REFERENCES users(id),
|
|
||||||
PRIMARY KEY (document_id, tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash, role)
|
|
||||||
VALUES (
|
|
||||||
gen_random_uuid(),
|
|
||||||
'admin',
|
|
||||||
'$argon2id$v=19$m=19456,t=2,p=1$UMkfsNut028fmZupy9JoQg$/YFvGQoEZ2hhMiDCyv68ZROF97GcwAxxRwRgwSbpX5U',
|
|
||||||
'admin'
|
|
||||||
);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
|
||||||
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
|
|
||||||
DROP INDEX IF EXISTS idx_jobs_job_type;
|
|
||||||
DROP INDEX IF EXISTS idx_jobs_status_run_after;
|
|
||||||
DROP TABLE IF EXISTS jobs;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
CREATE TABLE jobs (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
job_type TEXT NOT NULL,
|
|
||||||
payload JSONB NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
|
||||||
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = now();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_jobs_updated_at
|
|
||||||
BEFORE UPDATE ON jobs
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION touch_jobs_updated_at();
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_document_assets_type;
|
|
||||||
DROP INDEX IF EXISTS idx_document_assets_version;
|
|
||||||
DROP TABLE IF EXISTS document_assets;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
CREATE TABLE document_assets (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
|
||||||
asset_type TEXT NOT NULL,
|
|
||||||
s3_key TEXT NOT NULL,
|
|
||||||
mime_type TEXT NOT NULL,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
|
|
||||||
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
|
|
||||||
|
|
||||||
ALTER TABLE folders
|
|
||||||
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
ALTER TABLE folders
|
|
||||||
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
|
||||||
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
DROP COLUMN issued_at;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
ADD COLUMN issued_at TIMESTAMPTZ;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
DROP COLUMN name;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
ADD COLUMN name VARCHAR(255);
|
|
||||||
|
|
||||||
UPDATE documents
|
|
||||||
SET name = CASE
|
|
||||||
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
|
|
||||||
ELSE filename
|
|
||||||
END;
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
ALTER COLUMN name SET NOT NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
RENAME COLUMN title TO name;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
RENAME COLUMN name TO title;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE refresh_tokens;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
CREATE TABLE refresh_tokens (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
issued_at TIMESTAMPTZ NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
|
||||||
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Restore width/height columns and repopulate from metadata where available.
|
|
||||||
|
|
||||||
ALTER TABLE document_assets
|
|
||||||
ADD COLUMN width INTEGER,
|
|
||||||
ADD COLUMN height INTEGER;
|
|
||||||
|
|
||||||
UPDATE document_assets
|
|
||||||
SET width = (metadata->>'width')::INTEGER
|
|
||||||
WHERE metadata ? 'width';
|
|
||||||
|
|
||||||
UPDATE document_assets
|
|
||||||
SET height = (metadata->>'height')::INTEGER
|
|
||||||
WHERE metadata ? 'height';
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- Backfill existing width/height values into metadata then drop the columns.
|
|
||||||
|
|
||||||
UPDATE document_assets
|
|
||||||
SET metadata = metadata || jsonb_build_object('width', width)
|
|
||||||
WHERE width IS NOT NULL
|
|
||||||
AND NOT (metadata ? 'width');
|
|
||||||
|
|
||||||
UPDATE document_assets
|
|
||||||
SET metadata = metadata || jsonb_build_object('height', height)
|
|
||||||
WHERE height IS NOT NULL
|
|
||||||
AND NOT (metadata ? 'height');
|
|
||||||
|
|
||||||
ALTER TABLE document_assets
|
|
||||||
DROP COLUMN width,
|
|
||||||
DROP COLUMN height;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
ALTER TABLE documents ADD COLUMN current_version INT4;
|
|
||||||
|
|
||||||
UPDATE documents AS d
|
|
||||||
SET current_version = dv.version_number
|
|
||||||
FROM document_versions AS dv
|
|
||||||
WHERE dv.id = d.current_version_id;
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
ALTER COLUMN current_version SET NOT NULL;
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS idx_documents_current_version_id;
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
DROP COLUMN current_version_id;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
ALTER TABLE documents ADD COLUMN current_version_id UUID;
|
|
||||||
|
|
||||||
UPDATE documents AS d
|
|
||||||
SET current_version_id = dv.id
|
|
||||||
FROM document_versions AS dv
|
|
||||||
WHERE dv.document_id = d.id
|
|
||||||
AND dv.version_number = d.current_version;
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
ALTER COLUMN current_version_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE documents
|
|
||||||
ADD CONSTRAINT documents_current_version_fk
|
|
||||||
FOREIGN KEY (current_version_id)
|
|
||||||
REFERENCES document_versions(id)
|
|
||||||
DEFERRABLE INITIALLY DEFERRED;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_current_version_id
|
|
||||||
ON documents(current_version_id);
|
|
||||||
|
|
||||||
ALTER TABLE documents DROP COLUMN current_version;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_document_correspondents_role;
|
|
||||||
DROP INDEX IF EXISTS idx_document_correspondents_correspondent;
|
|
||||||
DROP INDEX IF EXISTS idx_document_correspondents_document;
|
|
||||||
DROP TABLE IF EXISTS document_correspondents;
|
|
||||||
DROP TABLE IF EXISTS correspondents;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
CREATE TABLE correspondents (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE document_correspondents (
|
|
||||||
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
||||||
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
|
||||||
role VARCHAR(32) NOT NULL,
|
|
||||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
assigned_by UUID REFERENCES users(id),
|
|
||||||
PRIMARY KEY (document_id, correspondent_id, role),
|
|
||||||
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_correspondents_document
|
|
||||||
ON document_correspondents(document_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_correspondents_correspondent
|
|
||||||
ON document_correspondents(correspondent_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_correspondents_role
|
|
||||||
ON document_correspondents(role);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_documents_folder_filename;
|
|
||||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
CREATE INDEX idx_documents_folder_title
|
|
||||||
ON documents (
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
title
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder_filename
|
|
||||||
ON documents (
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
filename
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
|
||||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder_title
|
|
||||||
ON documents (
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
title
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder_filename
|
|
||||||
ON documents (
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
filename
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_documents_folder_title;
|
|
||||||
DROP INDEX IF EXISTS idx_documents_folder_filename;
|
|
||||||
DROP INDEX IF EXISTS documents_unique_folder_title;
|
|
||||||
DROP INDEX IF EXISTS documents_unique_folder_filename;
|
|
||||||
|
|
||||||
CREATE INDEX idx_documents_folder_title
|
|
||||||
ON documents (
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
title
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX documents_unique_folder_filename
|
|
||||||
ON documents (
|
|
||||||
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
||||||
filename
|
|
||||||
)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE document_versions
|
|
||||||
DROP COLUMN metadata;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE document_versions
|
|
||||||
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE folders
|
|
||||||
ADD COLUMN path_cache VARCHAR(1000);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE folders
|
|
||||||
DROP COLUMN path_cache;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
ALTER TABLE document_assets
|
|
||||||
ADD COLUMN s3_key TEXT;
|
|
||||||
|
|
||||||
UPDATE document_assets AS da
|
|
||||||
SET s3_key = dao.s3_key
|
|
||||||
FROM document_asset_objects AS dao
|
|
||||||
WHERE dao.asset_id = da.id
|
|
||||||
AND dao.ordinal = 1
|
|
||||||
AND da.s3_key IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE document_assets
|
|
||||||
ALTER COLUMN s3_key SET NOT NULL;
|
|
||||||
|
|
||||||
UPDATE document_assets AS da
|
|
||||||
SET metadata = jsonb_set(da.metadata, '{width}', dao.metadata->'width', true)
|
|
||||||
FROM document_asset_objects AS dao
|
|
||||||
WHERE dao.asset_id = da.id
|
|
||||||
AND dao.ordinal = 1
|
|
||||||
AND dao.metadata ? 'width';
|
|
||||||
|
|
||||||
UPDATE document_assets AS da
|
|
||||||
SET metadata = jsonb_set(da.metadata, '{height}', dao.metadata->'height', true)
|
|
||||||
FROM document_asset_objects AS dao
|
|
||||||
WHERE dao.asset_id = da.id
|
|
||||||
AND dao.ordinal = 1
|
|
||||||
AND dao.metadata ? 'height';
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS idx_document_asset_objects_asset_ordinal;
|
|
||||||
DROP TABLE IF EXISTS document_asset_objects;
|
|
||||||
|
|
||||||
ALTER TABLE document_assets
|
|
||||||
DROP CONSTRAINT IF EXISTS document_assets_cardinality_positive;
|
|
||||||
ALTER TABLE document_assets
|
|
||||||
DROP COLUMN IF EXISTS cardinality;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
ALTER TABLE document_assets
|
|
||||||
ADD COLUMN cardinality INTEGER,
|
|
||||||
ADD CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1);
|
|
||||||
|
|
||||||
CREATE TABLE document_asset_objects (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
s3_key TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
|
|
||||||
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
|
||||||
ON document_asset_objects (asset_id, ordinal);
|
|
||||||
|
|
||||||
INSERT INTO document_asset_objects (id, asset_id, ordinal, s3_key, metadata)
|
|
||||||
SELECT
|
|
||||||
gen_random_uuid(),
|
|
||||||
id,
|
|
||||||
1,
|
|
||||||
s3_key,
|
|
||||||
'{}'::jsonb
|
|
||||||
FROM document_assets;
|
|
||||||
|
|
||||||
UPDATE document_asset_objects AS dao
|
|
||||||
SET metadata = jsonb_set(dao.metadata, '{width}', da.metadata->'width', true)
|
|
||||||
FROM document_assets AS da
|
|
||||||
WHERE dao.asset_id = da.id
|
|
||||||
AND dao.ordinal = 1
|
|
||||||
AND da.metadata ? 'width';
|
|
||||||
|
|
||||||
UPDATE document_asset_objects AS dao
|
|
||||||
SET metadata = jsonb_set(dao.metadata, '{height}', da.metadata->'height', true)
|
|
||||||
FROM document_assets AS da
|
|
||||||
WHERE dao.asset_id = da.id
|
|
||||||
AND dao.ordinal = 1
|
|
||||||
AND da.metadata ? 'height';
|
|
||||||
|
|
||||||
UPDATE document_assets
|
|
||||||
SET metadata = metadata - 'width'
|
|
||||||
WHERE metadata ? 'width';
|
|
||||||
|
|
||||||
UPDATE document_assets
|
|
||||||
SET metadata = metadata - 'height'
|
|
||||||
WHERE metadata ? 'height';
|
|
||||||
|
|
||||||
UPDATE document_assets
|
|
||||||
SET cardinality = 1
|
|
||||||
WHERE cardinality IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE document_assets
|
|
||||||
DROP COLUMN s3_key;
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
|
||||||
|
DROP FUNCTION IF EXISTS touch_jobs_updated_at();
|
||||||
|
|
||||||
|
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS document_asset_objects;
|
||||||
|
DROP TABLE IF EXISTS document_assets;
|
||||||
|
DROP TABLE IF EXISTS document_versions;
|
||||||
|
DROP TABLE IF EXISTS document_tags;
|
||||||
|
DROP TABLE IF EXISTS document_correspondents;
|
||||||
|
DROP TABLE IF EXISTS correspondents;
|
||||||
|
DROP TABLE IF EXISTS documents;
|
||||||
|
DROP TABLE IF EXISTS folders;
|
||||||
|
DROP TABLE IF EXISTS tags;
|
||||||
|
DROP TABLE IF EXISTS jobs;
|
||||||
|
DROP TABLE IF EXISTS refresh_tokens;
|
||||||
|
DROP TABLE IF EXISTS user_memberships;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
DROP TABLE IF EXISTS tenants;
|
||||||
|
|
||||||
|
DROP EXTENSION IF EXISTS "pgcrypto";
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE tenants (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
storage_root TEXT,
|
||||||
|
quickwit_index TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tenants_storage_root_unique
|
||||||
|
ON tenants (storage_root)
|
||||||
|
WHERE storage_root IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tenants_quickwit_index_unique
|
||||||
|
ON tenants (quickwit_index)
|
||||||
|
WHERE quickwit_index IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_memberships (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (user_id, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (tenant_id);
|
||||||
|
CREATE INDEX user_memberships_user_id_idx ON user_memberships (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE folders (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
parent_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_folders_parent ON folders(parent_id);
|
||||||
|
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
|
||||||
|
CREATE UNIQUE INDEX folders_parent_name_unique_idx
|
||||||
|
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
|
||||||
|
|
||||||
|
CREATE TABLE documents (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100),
|
||||||
|
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
issued_at TIMESTAMPTZ,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
current_version_id UUID NOT NULL,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_documents_folder ON documents (folder_id);
|
||||||
|
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at);
|
||||||
|
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id);
|
||||||
|
CREATE INDEX idx_documents_current_version_id ON documents (current_version_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_documents_folder_title
|
||||||
|
ON documents (
|
||||||
|
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
title
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX documents_unique_folder_filename
|
||||||
|
ON documents (
|
||||||
|
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE document_versions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
version_number INT NOT NULL,
|
||||||
|
s3_key VARCHAR(500) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
checksum VARCHAR(64) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_versions_document ON document_versions (document_id);
|
||||||
|
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id);
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD CONSTRAINT documents_current_version_fk
|
||||||
|
FOREIGN KEY (current_version_id)
|
||||||
|
REFERENCES document_versions(id)
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
label VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(7),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX tags_tenant_id_idx ON tags (tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE document_tags (
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
assigned_by UUID REFERENCES users(id),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
PRIMARY KEY (document_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_tags_tag ON document_tags (tag_id);
|
||||||
|
CREATE INDEX document_tags_tenant_id_idx ON document_tags (tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE correspondents (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
CONSTRAINT correspondents_name_unique UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE document_correspondents (
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(32) NOT NULL,
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
assigned_by UUID REFERENCES users(id),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
PRIMARY KEY (document_id, correspondent_id, role),
|
||||||
|
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_id);
|
||||||
|
CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents (correspondent_id);
|
||||||
|
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role);
|
||||||
|
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE document_assets (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||||
|
asset_type TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
cardinality INTEGER,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type),
|
||||||
|
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_assets_version ON document_assets (document_version_id);
|
||||||
|
CREATE INDEX idx_document_assets_type ON document_assets (asset_type);
|
||||||
|
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE document_asset_objects (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
s3_key TEXT NOT NULL,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
|
||||||
|
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_asset_objects_asset_ordinal
|
||||||
|
ON document_asset_objects (asset_id, ordinal);
|
||||||
|
|
||||||
|
CREATE INDEX document_asset_objects_tenant_id_idx
|
||||||
|
ON document_asset_objects (tenant_id);
|
||||||
|
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
job_type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||||
|
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
|
||||||
|
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
|
||||||
|
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_jobs_updated_at
|
||||||
|
BEFORE UPDATE ON jobs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION touch_jobs_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);
|
||||||
|
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash);
|
||||||
|
CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens (tenant_id);
|
||||||
@@ -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);
|
||||||
+46
-4
@@ -15,6 +15,8 @@ pub struct JwtService {
|
|||||||
expiry: Duration,
|
expiry: Duration,
|
||||||
download_audience: String,
|
download_audience: String,
|
||||||
download_expiry: Duration,
|
download_expiry: Duration,
|
||||||
|
selector_audience: String,
|
||||||
|
selector_expiry: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JwtService {
|
impl JwtService {
|
||||||
@@ -27,16 +29,18 @@ impl JwtService {
|
|||||||
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
||||||
download_audience: config.download_token_audience.clone(),
|
download_audience: config.download_token_audience.clone(),
|
||||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
||||||
|
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
||||||
|
selector_expiry: Duration::minutes(15),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_token(&self, user_id: Uuid, username: &str, role: &str) -> Result<String> {
|
pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + self.expiry;
|
let exp = now + self.expiry;
|
||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
|
tenant_id,
|
||||||
username: username.to_owned(),
|
username: username.to_owned(),
|
||||||
role: role.to_owned(),
|
|
||||||
iss: self.issuer.clone(),
|
iss: self.issuer.clone(),
|
||||||
aud: self.audience.clone(),
|
aud: self.audience.clone(),
|
||||||
iat: now.timestamp() as usize,
|
iat: now.timestamp() as usize,
|
||||||
@@ -54,12 +58,18 @@ impl JwtService {
|
|||||||
Ok(data.claims)
|
Ok(data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> {
|
pub fn generate_download_token(
|
||||||
|
&self,
|
||||||
|
document_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> Result<String> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + self.download_expiry;
|
let exp = now + self.download_expiry;
|
||||||
let claims = DownloadClaims {
|
let claims = DownloadClaims {
|
||||||
doc_id: document_id,
|
doc_id: document_id,
|
||||||
user_id,
|
user_id,
|
||||||
|
tenant_id,
|
||||||
iss: self.issuer.clone(),
|
iss: self.issuer.clone(),
|
||||||
aud: self.download_audience.clone(),
|
aud: self.download_audience.clone(),
|
||||||
iat: now.timestamp() as usize,
|
iat: now.timestamp() as usize,
|
||||||
@@ -76,13 +86,35 @@ impl JwtService {
|
|||||||
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
|
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
|
||||||
Ok(data.claims)
|
Ok(data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result<String> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let exp = now + self.selector_expiry;
|
||||||
|
let claims = TenantSelectionClaims {
|
||||||
|
sub: user_id,
|
||||||
|
iss: self.issuer.clone(),
|
||||||
|
aud: self.selector_audience.clone(),
|
||||||
|
iat: now.timestamp() as usize,
|
||||||
|
exp: exp.timestamp() as usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_tenant_selector_token(&self, token: &str) -> Result<TenantSelectionClaims> {
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.set_audience(&[self.selector_audience.clone()]);
|
||||||
|
validation.set_issuer(&[self.issuer.clone()]);
|
||||||
|
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
||||||
|
Ok(data.claims)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: Uuid,
|
pub sub: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub role: String,
|
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
pub aud: String,
|
pub aud: String,
|
||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
@@ -93,6 +125,16 @@ pub struct Claims {
|
|||||||
pub struct DownloadClaims {
|
pub struct DownloadClaims {
|
||||||
pub doc_id: Uuid,
|
pub doc_id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: String,
|
||||||
|
pub iat: usize,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TenantSelectionClaims {
|
||||||
|
pub sub: Uuid,
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
pub aud: String,
|
pub aud: String,
|
||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
|
|||||||
+50
-4
@@ -6,13 +6,17 @@ use axum_extra::headers::{authorization::Bearer, Authorization};
|
|||||||
use axum_extra::TypedHeader;
|
use axum_extra::TypedHeader;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{error::AppError, state::AppState};
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
state::{AppState, PgPooledConnection},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
pub user_id: uuid::Uuid,
|
pub user_id: uuid::Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub role: String,
|
pub tenant_id: uuid::Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -23,6 +27,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||||
|
return Ok(user.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let TypedHeader(Authorization(bearer)) =
|
let TypedHeader(Authorization(bearer)) =
|
||||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
||||||
.await
|
.await
|
||||||
@@ -33,10 +41,48 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
|||||||
.verify_token(bearer.token())
|
.verify_token(bearer.token())
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
Ok(AuthenticatedUser {
|
let user = AuthenticatedUser {
|
||||||
user_id: claims.sub,
|
user_id: claims.sub,
|
||||||
username: claims.username,
|
username: claims.username,
|
||||||
role: claims.role,
|
tenant_id: claims.tenant_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
parts.extensions.insert(user.clone());
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TenantScopedConn {
|
||||||
|
pub conn: PgPooledConnection,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub user: AuthenticatedUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantScopedConn {
|
||||||
|
pub fn conn(&mut self) -> &mut PgPooledConnection {
|
||||||
|
&mut self.conn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FromRequestParts<AppState> for TenantScopedConn {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
|
||||||
|
let tenant_id = user.tenant_id;
|
||||||
|
let conn = state.db_for_tenant(tenant_id)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id: user.user_id,
|
||||||
|
user,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,707 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use diesel::{dsl::exists, prelude::*, select};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use reqwest::{Client, Method, StatusCode};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use backend::{
|
||||||
|
config::AppConfig,
|
||||||
|
db::{self, PgPool},
|
||||||
|
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||||
|
models::{DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, User},
|
||||||
|
s3,
|
||||||
|
schema::{
|
||||||
|
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
|
||||||
|
},
|
||||||
|
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||||
|
utils::tracing::init_tracing,
|
||||||
|
};
|
||||||
|
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
static QUICKWIT_INDEX_TEMPLATE: Lazy<serde_json::Value> = Lazy::new(|| {
|
||||||
|
json!({
|
||||||
|
"version": "0.8",
|
||||||
|
"index_id": "documents",
|
||||||
|
"doc_mapping": {
|
||||||
|
"tokenizers": [
|
||||||
|
{
|
||||||
|
"name": "substring",
|
||||||
|
"type": "ngram",
|
||||||
|
"min_gram": 2,
|
||||||
|
"max_gram": 20,
|
||||||
|
"prefix_only": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"field_mappings": [
|
||||||
|
{ "name": "tenant_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "document_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "version_id", "type": "text", "stored": true },
|
||||||
|
{ "name": "title", "type": "text", "tokenizer": "substring", "stored": true },
|
||||||
|
{ "name": "text", "type": "text", "tokenizer": "substring", "record": "position" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"search_settings": {
|
||||||
|
"default_search_fields": ["title", "text"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Command {
|
||||||
|
CreateUser {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
SetPassword {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
ListUsers,
|
||||||
|
DeleteUser {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
CreateTenant {
|
||||||
|
slug: String,
|
||||||
|
storage_root: Option<String>,
|
||||||
|
quickwit_index: Option<String>,
|
||||||
|
},
|
||||||
|
DeleteTenant {
|
||||||
|
slug: String,
|
||||||
|
},
|
||||||
|
AddUserToTenant {
|
||||||
|
username: String,
|
||||||
|
slug: String,
|
||||||
|
role: Option<String>,
|
||||||
|
},
|
||||||
|
RemoveUserFromTenant {
|
||||||
|
username: String,
|
||||||
|
slug: String,
|
||||||
|
},
|
||||||
|
ReanalyzeDocuments {
|
||||||
|
slug: String,
|
||||||
|
},
|
||||||
|
ListTenants,
|
||||||
|
DeleteAssets(String),
|
||||||
|
QuickwitCreate(String),
|
||||||
|
QuickwitDelete(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
fn usage() -> &'static str {
|
||||||
|
"Usage: admin\n\
|
||||||
|
create-user <username> <password>\n\
|
||||||
|
set-password <username> <password>\n\
|
||||||
|
list-users\n\
|
||||||
|
delete-user <username>\n\
|
||||||
|
create-tenant <slug> [storage_root] [quickwit_index]\n\
|
||||||
|
delete-tenant <slug>\n\
|
||||||
|
add-user-to-tenant <username> <slug> [role]\n\
|
||||||
|
remove-user-from-tenant <username> <slug>\n\
|
||||||
|
reanalyze-documents <slug>\n\
|
||||||
|
list-tenants\n\
|
||||||
|
delete-assets <slug>\n\
|
||||||
|
quickwit-create-index <slug>\n\
|
||||||
|
quickwit-delete-index <slug>"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse() -> Result<Self> {
|
||||||
|
let mut args = env::args().skip(1);
|
||||||
|
match args.next().as_deref() {
|
||||||
|
Some("create-user") => Ok(Self::CreateUser {
|
||||||
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
|
password: args.next().ok_or_else(|| anyhow!("password required"))?,
|
||||||
|
}),
|
||||||
|
Some("set-password") => Ok(Self::SetPassword {
|
||||||
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
|
password: args.next().ok_or_else(|| anyhow!("password required"))?,
|
||||||
|
}),
|
||||||
|
Some("list-users") => Ok(Self::ListUsers),
|
||||||
|
Some("delete-user") => Ok(Self::DeleteUser {
|
||||||
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
|
}),
|
||||||
|
Some("create-tenant") => Ok(Self::CreateTenant {
|
||||||
|
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
storage_root: args.next(),
|
||||||
|
quickwit_index: args.next(),
|
||||||
|
}),
|
||||||
|
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
||||||
|
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
}),
|
||||||
|
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
||||||
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
|
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
role: args.next(),
|
||||||
|
}),
|
||||||
|
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
||||||
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
|
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
}),
|
||||||
|
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
||||||
|
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
}),
|
||||||
|
Some("list-tenants") => Ok(Self::ListTenants),
|
||||||
|
Some("delete-assets") => Ok(Self::DeleteAssets(
|
||||||
|
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
)),
|
||||||
|
Some("quickwit-create-index") => Ok(Self::QuickwitCreate(
|
||||||
|
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
)),
|
||||||
|
Some("quickwit-delete-index") => Ok(Self::QuickwitDelete(
|
||||||
|
args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
|
)),
|
||||||
|
_ => Err(anyhow!(Self::usage())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
init_tracing("info");
|
||||||
|
let command = Command::parse()?;
|
||||||
|
let config = AppConfig::load_and_log("admin")?;
|
||||||
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?,
|
||||||
|
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
|
||||||
|
Command::ListUsers => list_users(&pool)?,
|
||||||
|
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||||
|
Command::CreateTenant {
|
||||||
|
slug,
|
||||||
|
storage_root,
|
||||||
|
quickwit_index,
|
||||||
|
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
|
||||||
|
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
|
||||||
|
Command::AddUserToTenant {
|
||||||
|
username,
|
||||||
|
slug,
|
||||||
|
role,
|
||||||
|
} => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?,
|
||||||
|
Command::RemoveUserFromTenant { username, slug } => {
|
||||||
|
remove_user_from_tenant(&pool, &username, &slug)?
|
||||||
|
}
|
||||||
|
Command::ReanalyzeDocuments { slug } => reanalyze_documents(&pool, &slug)?,
|
||||||
|
Command::ListTenants => list_tenants(&pool)?,
|
||||||
|
Command::DeleteAssets(slug) => delete_assets_for_tenant(&config, &pool, &slug).await?,
|
||||||
|
Command::QuickwitCreate(slug) => {
|
||||||
|
quickwit_index(&config, &pool, &slug, Method::POST).await?
|
||||||
|
}
|
||||||
|
Command::QuickwitDelete(slug) => {
|
||||||
|
quickwit_index(&config, &pool, &slug, Method::DELETE).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
||||||
|
if username.trim().is_empty() {
|
||||||
|
bail!("username must not be empty");
|
||||||
|
}
|
||||||
|
if password.is_empty() {
|
||||||
|
bail!("password must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
let exists: bool =
|
||||||
|
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?;
|
||||||
|
if exists {
|
||||||
|
bail!("user '{}' already exists", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash = hash_password(password)?;
|
||||||
|
let new_user = NewUser {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
username: username.to_string(),
|
||||||
|
password_hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
println!("created user '{}' (id: {})", username, new_user.id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> {
|
||||||
|
if password.is_empty() {
|
||||||
|
bail!("password must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
let password_hash = hash_password(password)?;
|
||||||
|
|
||||||
|
let updated = diesel::update(users::table.filter(users::username.eq(username)))
|
||||||
|
.set(users::password_hash.eq(password_hash))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
if updated == 0 {
|
||||||
|
bail!("user '{}' not found", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("updated password for '{}'", username);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_password(password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_users(pool: &PgPool) -> Result<()> {
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
let users_list: Vec<User> = users::table.order(users::username.asc()).load(&mut conn)?;
|
||||||
|
if users_list.is_empty() {
|
||||||
|
println!("No users found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in users_list {
|
||||||
|
let memberships: Vec<(Uuid, String, String)> = user_memberships::table
|
||||||
|
.inner_join(tenants::table)
|
||||||
|
.filter(user_memberships::user_id.eq(user.id))
|
||||||
|
.select((tenants::id, tenants::slug, user_memberships::role))
|
||||||
|
.order((tenants::slug.asc(), user_memberships::role.asc()))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
if memberships.is_empty() {
|
||||||
|
println!("{} ({})", user.username, user.id);
|
||||||
|
} else {
|
||||||
|
let details: Vec<String> = memberships
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, slug, role)| format!("{}: {}", slug, role))
|
||||||
|
.collect();
|
||||||
|
println!("{} ({}) -> {}", user.username, user.id, details.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
let user: User = users::table
|
||||||
|
.filter(users::username.eq(username))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||||
|
|
||||||
|
diesel::delete(user_memberships::table.filter(user_memberships::user_id.eq(user.id)))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?;
|
||||||
|
|
||||||
|
println!("deleted user '{}'", username);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tenant(
|
||||||
|
pool: &PgPool,
|
||||||
|
slug: &str,
|
||||||
|
storage_root_arg: Option<String>,
|
||||||
|
quickwit_index_arg: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if slug.trim().is_empty() {
|
||||||
|
bail!("tenant slug must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
let exists: bool =
|
||||||
|
select(exists(tenants::table.filter(tenants::slug.eq(slug)))).get_result(&mut conn)?;
|
||||||
|
if exists {
|
||||||
|
bail!("tenant '{}' already exists", slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let storage_root = storage_root_arg
|
||||||
|
.map(|mut s| {
|
||||||
|
if s.is_empty() {
|
||||||
|
format!("tenants/{}/", id)
|
||||||
|
} else {
|
||||||
|
if !s.ends_with('/') {
|
||||||
|
s.push('/');
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| format!("tenants/{}/", id));
|
||||||
|
let quickwit_index = quickwit_index_arg.unwrap_or_else(|| format!("documents-{}", id));
|
||||||
|
|
||||||
|
diesel::insert_into(tenants::table)
|
||||||
|
.values((
|
||||||
|
tenants::id.eq(id),
|
||||||
|
tenants::slug.eq(slug),
|
||||||
|
tenants::storage_root.eq(Some(storage_root.clone())),
|
||||||
|
tenants::quickwit_index.eq(Some(quickwit_index.clone())),
|
||||||
|
tenants::status.eq("active"),
|
||||||
|
tenants::config.eq(serde_json::json!({})),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}'",
|
||||||
|
slug, id, storage_root, quickwit_index
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> {
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
let tenant: Tenant = tenants::table
|
||||||
|
.filter(tenants::slug.eq(slug))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||||
|
|
||||||
|
let member_exists: bool = select(exists(
|
||||||
|
user_memberships::table.filter(user_memberships::tenant_id.eq(tenant.id)),
|
||||||
|
))
|
||||||
|
.get_result(&mut conn)?;
|
||||||
|
if member_exists {
|
||||||
|
bail!("tenant '{}' still has user memberships", slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::delete(tenants::table.filter(tenants::id.eq(tenant.id))).execute(&mut conn)?;
|
||||||
|
println!("deleted tenant '{}'", slug);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> {
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
let user: User = users::table
|
||||||
|
.filter(users::username.eq(username))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||||
|
|
||||||
|
let tenant: Tenant = tenants::table
|
||||||
|
.filter(tenants::slug.eq(slug))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||||
|
|
||||||
|
let membership = NewUserMembership {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
role: role.unwrap_or("user").to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(user_memberships::table)
|
||||||
|
.values(&membership)
|
||||||
|
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||||
|
.do_update()
|
||||||
|
.set(user_memberships::role.eq(&membership.role))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"added user '{}' to tenant '{}' with role '{}'",
|
||||||
|
username, slug, membership.role
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_user_from_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> {
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
let user: User = users::table
|
||||||
|
.filter(users::username.eq(username))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("user '{}' not found", username))?;
|
||||||
|
|
||||||
|
let tenant: Tenant = tenants::table
|
||||||
|
.filter(tenants::slug.eq(slug))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||||
|
|
||||||
|
let removed = diesel::delete(
|
||||||
|
user_memberships::table
|
||||||
|
.filter(user_memberships::user_id.eq(user.id))
|
||||||
|
.filter(user_memberships::tenant_id.eq(tenant.id)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
|
if removed == 0 {
|
||||||
|
println!("user '{}' was not a member of tenant '{}'", username, slug);
|
||||||
|
} else {
|
||||||
|
println!("removed user '{}' from tenant '{}'", username, slug);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reanalyze_documents(pool: &PgPool, slug: &str) -> Result<()> {
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
|
let tenant: Tenant = tenants::table
|
||||||
|
.filter(tenants::slug.eq(slug))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||||
|
|
||||||
|
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||||
|
.filter(documents::tenant_id.eq(tenant.id))
|
||||||
|
.filter(documents::deleted_at.is_null())
|
||||||
|
.select((documents::id, documents::current_version_id))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
if targets.is_empty() {
|
||||||
|
println!("tenant '{}' has no active documents", slug);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut queued = 0usize;
|
||||||
|
for (document_id, version_id) in targets {
|
||||||
|
enqueue_job(
|
||||||
|
&mut conn,
|
||||||
|
tenant.id,
|
||||||
|
JOB_ANALYZE_DOCUMENT,
|
||||||
|
serde_json::json!({
|
||||||
|
"document_id": document_id,
|
||||||
|
"document_version_id": version_id,
|
||||||
|
"force": true,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?;
|
||||||
|
queued += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"queued {} documents for re-analysis in tenant '{}'",
|
||||||
|
queued, slug
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_tenants(pool: &PgPool) -> Result<()> {
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
let tenants: Vec<Tenant> = tenants::table
|
||||||
|
.order(tenants::slug.asc())
|
||||||
|
.load(&mut conn)
|
||||||
|
.context("failed to load tenants")?;
|
||||||
|
|
||||||
|
if tenants.is_empty() {
|
||||||
|
println!("No tenants found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for tenant in tenants {
|
||||||
|
println!("{} ({})", tenant.slug, tenant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_assets_for_tenant(
|
||||||
|
config: &AppConfig,
|
||||||
|
pool: &PgPool,
|
||||||
|
tenant_slug: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let s3_client = s3::build_client(config).await?;
|
||||||
|
let storage: Arc<dyn ObjectStorage> =
|
||||||
|
Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
|
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
let tenant: Tenant = tenants::table
|
||||||
|
.filter(tenants::slug.eq(tenant_slug))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()
|
||||||
|
.context("failed to load tenant")?
|
||||||
|
.ok_or_else(|| anyhow!("tenant '{}' not found", tenant_slug))?;
|
||||||
|
|
||||||
|
let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant)
|
||||||
|
.with_context(|| format!("missing storage root for tenant {}", tenant.slug))?;
|
||||||
|
|
||||||
|
let assets: Vec<DocumentAsset> = document_assets::table
|
||||||
|
.filter(document_assets::tenant_id.eq(tenant.id))
|
||||||
|
.load(&mut conn)
|
||||||
|
.with_context(|| format!("failed to load assets for tenant {}", tenant.slug))?;
|
||||||
|
|
||||||
|
if assets.is_empty() {
|
||||||
|
println!("Tenant {}: no assets", tenant.slug);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Tenant {} ({}): deleting {} assets…",
|
||||||
|
tenant.slug,
|
||||||
|
tenant.id,
|
||||||
|
assets.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
||||||
|
|
||||||
|
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
||||||
|
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
|
||||||
|
.load(&mut conn)
|
||||||
|
.with_context(|| format!("failed to load asset objects for tenant {}", tenant.slug))?;
|
||||||
|
|
||||||
|
for object in &objects {
|
||||||
|
if let Err(err) = tenant_storage.delete_object(&object.s3_key).await {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to delete object {} (tenant {}): {err}",
|
||||||
|
object.s3_key, tenant.slug
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::delete(
|
||||||
|
document_asset_objects::table
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant.id))
|
||||||
|
.filter(document_asset_objects::asset_id.eq_any(&asset_ids)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.with_context(|| format!("failed to remove asset objects for tenant {}", tenant.slug))?;
|
||||||
|
|
||||||
|
diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant.id)))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.with_context(|| format!("failed to remove asset records for tenant {}", tenant.slug))?;
|
||||||
|
|
||||||
|
println!("Tenant {}: asset records deleted.", tenant.slug);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn quickwit_index(
|
||||||
|
config: &AppConfig,
|
||||||
|
pool: &PgPool,
|
||||||
|
slug: &str,
|
||||||
|
method: Method,
|
||||||
|
) -> Result<()> {
|
||||||
|
let endpoint = config
|
||||||
|
.quickwit_endpoint
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("quickwit endpoint not configured"))?;
|
||||||
|
|
||||||
|
let mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
let tenant: Tenant = tenants::table
|
||||||
|
.filter(tenants::slug.eq(slug))
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()
|
||||||
|
.context("failed to query tenants")?
|
||||||
|
.ok_or_else(|| anyhow!("tenant '{}' not found", slug))?;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let index_id = format!("documents-{}", tenant.id);
|
||||||
|
let base_endpoint = endpoint.trim_end_matches('/');
|
||||||
|
|
||||||
|
match method {
|
||||||
|
Method::POST => {
|
||||||
|
let payload = render_index_template(&index_id);
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/api/v1/indexes", base_endpoint))
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to send create index request")?;
|
||||||
|
|
||||||
|
match response.status() {
|
||||||
|
status if status.is_success() => {
|
||||||
|
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||||
|
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.context("failed to update tenant quickwit_index")?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Tenant '{}' quickwit index set to '{}'.",
|
||||||
|
tenant.slug, index_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
StatusCode::CONFLICT => {
|
||||||
|
let lookup = client
|
||||||
|
.get(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to verify existing quickwit index")?;
|
||||||
|
|
||||||
|
let lookup_status = lookup.status();
|
||||||
|
if !lookup_status.is_success() {
|
||||||
|
let body = lookup.text().await.unwrap_or_default();
|
||||||
|
bail!(
|
||||||
|
"quickwit reported conflict but index lookup failed with status {}: {}",
|
||||||
|
lookup_status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||||
|
.set(tenants::quickwit_index.eq(Some(index_id.clone())))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.context("failed to update tenant quickwit_index")?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Tenant '{}' quickwit index set to '{}'.",
|
||||||
|
tenant.slug, index_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
status => {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
bail!(
|
||||||
|
"quickwit create index failed with status {}: {}",
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Method::DELETE => {
|
||||||
|
let response = client
|
||||||
|
.delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to send delete index request")?;
|
||||||
|
|
||||||
|
match response.status() {
|
||||||
|
status if status.is_success() || status == StatusCode::NOT_FOUND => {
|
||||||
|
diesel::update(tenants::table.filter(tenants::id.eq(tenant.id)))
|
||||||
|
.set(tenants::quickwit_index.eq::<Option<String>>(None))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.context("failed to clear tenant quickwit_index")?;
|
||||||
|
|
||||||
|
println!("Tenant '{}' quickwit index cleared.", tenant.slug);
|
||||||
|
}
|
||||||
|
status => {
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
bail!(
|
||||||
|
"quickwit delete index failed with status {}: {}",
|
||||||
|
status,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_index_template(index_id: &str) -> String {
|
||||||
|
let mut template = QUICKWIT_INDEX_TEMPLATE.clone();
|
||||||
|
if let Some(obj) = template.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"index_id".to_string(),
|
||||||
|
serde_json::Value::String(index_id.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
template.to_string()
|
||||||
|
}
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use backend::{
|
|
||||||
config::AppConfig,
|
|
||||||
db,
|
|
||||||
models::{DocumentAsset, DocumentAssetObject},
|
|
||||||
s3,
|
|
||||||
schema::{document_asset_objects, document_assets},
|
|
||||||
storage::{ObjectStorage, S3Storage},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
let mut args = env::args().skip(1);
|
|
||||||
match args.next().as_deref() {
|
|
||||||
Some("delete-assets") => delete_all_assets().await?,
|
|
||||||
Some(cmd) => {
|
|
||||||
eprintln!("Unknown command: {cmd}\nUsage: maintenance delete-assets");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
eprintln!("Usage: maintenance delete-assets");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_all_assets() -> Result<()> {
|
|
||||||
let config = AppConfig::from_env()?;
|
|
||||||
tracing::info!(
|
|
||||||
component = "maintenance",
|
|
||||||
database_url = %config.redacted_database_url(),
|
|
||||||
pool_size = config.database_max_pool_size,
|
|
||||||
s3_bucket = %config.s3_bucket,
|
|
||||||
"loaded backend configuration"
|
|
||||||
);
|
|
||||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
|
||||||
|
|
||||||
let s3_client = s3::build_client(&config).await?;
|
|
||||||
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
|
|
||||||
|
|
||||||
let mut conn = pool.get().context("failed to get database connection")?;
|
|
||||||
|
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
|
||||||
.load(&mut conn)
|
|
||||||
.context("failed to load document assets")?;
|
|
||||||
|
|
||||||
if assets.is_empty() {
|
|
||||||
println!("No assets found.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Deleting {} assets…", assets.len());
|
|
||||||
|
|
||||||
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
|
||||||
|
|
||||||
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
|
||||||
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
|
|
||||||
.load(&mut conn)
|
|
||||||
.context("failed to load document asset objects")?;
|
|
||||||
|
|
||||||
for object in &objects {
|
|
||||||
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
|
||||||
eprintln!(
|
|
||||||
"Failed to delete object {} from storage: {err}",
|
|
||||||
object.s3_key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
diesel::delete(document_assets::table)
|
|
||||||
.execute(&mut conn)
|
|
||||||
.context("failed to remove asset records")?;
|
|
||||||
|
|
||||||
println!("Asset records deleted.");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,47 +1,24 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower::make::Shared;
|
use tower::make::Shared;
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
|
|
||||||
use backend::auth::jwt::JwtService;
|
use backend::{routes::webdav, utils::bootstrap::init_component};
|
||||||
use backend::config::AppConfig;
|
|
||||||
use backend::db;
|
|
||||||
use backend::routes::webdav;
|
|
||||||
use backend::s3::build_client;
|
|
||||||
use backend::state::AppState;
|
|
||||||
use backend::storage::S3Storage;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenv::dotenv().ok();
|
let state = init_component("webdav", None).await?;
|
||||||
init_tracing();
|
let webdav_host = state.config.webdav_host.clone();
|
||||||
|
let webdav_port = state.config.webdav_port;
|
||||||
let config = AppConfig::from_env()?;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
component = "webdav",
|
component = "webdav",
|
||||||
database_url = %config.redacted_database_url(),
|
webdav_host = %webdav_host,
|
||||||
pool_size = config.database_max_pool_size,
|
webdav_port,
|
||||||
server_host = %config.server_host,
|
"starting webdav server"
|
||||||
server_port = config.server_port,
|
|
||||||
webdav_host = %config.webdav_host,
|
|
||||||
webdav_port = config.webdav_port,
|
|
||||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
|
||||||
s3_bucket = %config.s3_bucket,
|
|
||||||
"loaded backend configuration"
|
|
||||||
);
|
);
|
||||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
|
||||||
let s3_client = build_client(&config).await?;
|
|
||||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
|
||||||
let jwt = JwtService::from_config(&config)?;
|
|
||||||
|
|
||||||
let state = AppState::new(pool, config, storage, jwt);
|
let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?;
|
||||||
let listen_addr: SocketAddr = {
|
let router = webdav::create_router().with_state(state.as_ref().clone());
|
||||||
let config = state.config.clone();
|
|
||||||
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
|
|
||||||
};
|
|
||||||
let router = webdav::create_router().with_state(state);
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(listen_addr).await?;
|
let listener = TcpListener::bind(listen_addr).await?;
|
||||||
tracing::info!("listening for WebDAV on {}", listen_addr);
|
tracing::info!("listening for WebDAV on {}", listen_addr);
|
||||||
@@ -49,12 +26,3 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
axum::serve(listener, Shared::new(router)).await?;
|
axum::serve(listener, Shared::new(router)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing() {
|
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(filter)
|
|
||||||
.with_target(false)
|
|
||||||
.compact()
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,13 @@
|
|||||||
use std::{sync::Arc, time::Duration};
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
|
|
||||||
use backend::{
|
use backend::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||||
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
|
|
||||||
state::AppState, storage::S3Storage, Worker,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenv::dotenv().ok();
|
let state = init_component("worker", Some(1)).await?;
|
||||||
init_tracing();
|
tracing::info!(component = "worker", "starting worker process");
|
||||||
|
|
||||||
let config = AppConfig::from_env()?;
|
|
||||||
tracing::info!(
|
|
||||||
component = "worker",
|
|
||||||
database_url = %config.redacted_database_url(),
|
|
||||||
pool_size = 1,
|
|
||||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
|
||||||
s3_bucket = %config.s3_bucket,
|
|
||||||
"loaded backend configuration"
|
|
||||||
);
|
|
||||||
let pool = db::init_pool_with_size(&config.database_url, 1)?;
|
|
||||||
let s3_client = build_client(&config).await?;
|
|
||||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
|
||||||
let jwt = JwtService::from_config(&config)?;
|
|
||||||
|
|
||||||
let state = Arc::new(AppState::new(pool, config, storage, jwt));
|
|
||||||
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -39,12 +19,3 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing() {
|
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(filter)
|
|
||||||
.with_target(false)
|
|
||||||
.compact()
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-2
@@ -30,9 +30,24 @@ pub struct AppConfig {
|
|||||||
pub s3_bucket: String,
|
pub s3_bucket: String,
|
||||||
pub quickwit_endpoint: Option<String>,
|
pub quickwit_endpoint: Option<String>,
|
||||||
pub quickwit_index: Option<String>,
|
pub quickwit_index: Option<String>,
|
||||||
|
pub default_tenant_slug: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
pub fn load_and_log(component: &str) -> Result<Self> {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
let config = Self::from_env()?;
|
||||||
|
tracing::info!(
|
||||||
|
component,
|
||||||
|
database_url = %config.redacted_database_url(),
|
||||||
|
pool_size = config.database_max_pool_size,
|
||||||
|
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||||
|
s3_bucket = %config.s3_bucket,
|
||||||
|
"loaded backend configuration"
|
||||||
|
);
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||||
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
||||||
@@ -79,6 +94,8 @@ impl AppConfig {
|
|||||||
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
|
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
|
||||||
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
|
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
|
||||||
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
|
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
|
||||||
|
let default_tenant_slug =
|
||||||
|
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
database_url,
|
database_url,
|
||||||
@@ -104,6 +121,7 @@ impl AppConfig {
|
|||||||
s3_bucket,
|
s3_bucket,
|
||||||
quickwit_endpoint,
|
quickwit_endpoint,
|
||||||
quickwit_index,
|
quickwit_index,
|
||||||
|
default_tenant_slug,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +133,12 @@ impl AppConfig {
|
|||||||
fn redact_database_url(raw: &str) -> String {
|
fn redact_database_url(raw: &str) -> String {
|
||||||
match Url::parse(raw) {
|
match Url::parse(raw) {
|
||||||
Ok(mut parsed) => {
|
Ok(mut parsed) => {
|
||||||
let _ = parsed.set_password(Some("*****"));
|
if parsed.password().is_some() {
|
||||||
parsed.to_string()
|
let _ = parsed.set_password(Some("*****"));
|
||||||
|
parsed.to_string()
|
||||||
|
} else {
|
||||||
|
raw.to_string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(_) => "***".to_string(),
|
Err(_) => "***".to_string(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub type AppResult<T> = Result<T, AppError>;
|
|||||||
pub struct AppError {
|
pub struct AppError {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
message: String,
|
message: String,
|
||||||
|
code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
@@ -19,6 +20,7 @@ impl AppError {
|
|||||||
Self {
|
Self {
|
||||||
status,
|
status,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
code: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +28,10 @@ impl AppError {
|
|||||||
Self::new(StatusCode::BAD_REQUEST, message)
|
Self::new(StatusCode::BAD_REQUEST, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn conflict(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::CONFLICT, message)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unauthorized() -> Self {
|
pub fn unauthorized() -> Self {
|
||||||
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
|
||||||
}
|
}
|
||||||
@@ -37,6 +43,11 @@ impl AppError {
|
|||||||
pub fn internal<E: Display>(error: E) -> Self {
|
pub fn internal<E: Display>(error: E) -> Self {
|
||||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
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 {
|
impl IntoResponse for AppError {
|
||||||
@@ -44,6 +55,7 @@ impl IntoResponse for AppError {
|
|||||||
let status = self.status;
|
let status = self.status;
|
||||||
let body = Json(ErrorResponse {
|
let body = Json(ErrorResponse {
|
||||||
error: self.message,
|
error: self.message,
|
||||||
|
code: self.code,
|
||||||
});
|
});
|
||||||
(status, body).into_response()
|
(status, body).into_response()
|
||||||
}
|
}
|
||||||
@@ -52,6 +64,8 @@ impl IntoResponse for AppError {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ErrorResponse {
|
struct ErrorResponse {
|
||||||
error: String,
|
error: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<diesel::result::Error> for AppError {
|
impl From<diesel::result::Error> for AppError {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
|
|||||||
|
|
||||||
pub fn enqueue_job(
|
pub fn enqueue_job(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
job_type: &str,
|
job_type: &str,
|
||||||
payload: Value,
|
payload: Value,
|
||||||
run_after: Option<NaiveDateTime>,
|
run_after: Option<NaiveDateTime>,
|
||||||
@@ -40,6 +41,7 @@ pub fn enqueue_job(
|
|||||||
payload,
|
payload,
|
||||||
status: STATUS_QUEUED.to_string(),
|
status: STATUS_QUEUED.to_string(),
|
||||||
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(jobs::table)
|
diesel::insert_into(jobs::table)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ pub mod db;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod openapi;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod s3;
|
pub mod s3;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod tenants;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod workers;
|
pub mod workers;
|
||||||
pub use workers::{default_handlers, Worker};
|
pub use workers::{default_handlers, Worker};
|
||||||
|
|||||||
+9
-38
@@ -1,57 +1,28 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower::make::Shared;
|
use tower::make::Shared;
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
|
|
||||||
use backend::auth::jwt::JwtService;
|
use backend::{routes, utils::bootstrap::init_component};
|
||||||
use backend::config::AppConfig;
|
|
||||||
use backend::db;
|
|
||||||
use backend::routes;
|
|
||||||
use backend::s3::build_client;
|
|
||||||
use backend::state::AppState;
|
|
||||||
use backend::storage::S3Storage;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
dotenv::dotenv().ok();
|
let state = init_component("api", None).await?;
|
||||||
init_tracing();
|
let server_host = state.config.server_host.clone();
|
||||||
|
let server_port = state.config.server_port;
|
||||||
let config = AppConfig::from_env()?;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
component = "api",
|
component = "api",
|
||||||
database_url = %config.redacted_database_url(),
|
server_host = %server_host,
|
||||||
pool_size = config.database_max_pool_size,
|
server_port,
|
||||||
server_host = %config.server_host,
|
"starting api server"
|
||||||
server_port = config.server_port,
|
|
||||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
|
||||||
s3_bucket = %config.s3_bucket,
|
|
||||||
"loaded backend configuration"
|
|
||||||
);
|
);
|
||||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
|
||||||
let s3_client = build_client(&config).await?;
|
|
||||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
|
||||||
let jwt = JwtService::from_config(&config)?;
|
|
||||||
|
|
||||||
let state = AppState::new(pool, config, storage, jwt);
|
let router = routes::create_router(state.as_ref().clone());
|
||||||
|
|
||||||
let router = routes::create_router(state.clone());
|
let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
|
||||||
|
|
||||||
let addr: SocketAddr =
|
|
||||||
format!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
|
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
tracing::info!("listening on {}", addr);
|
tracing::info!("listening on {}", addr);
|
||||||
|
|
||||||
axum::serve(listener, Shared::new(router)).await?;
|
axum::serve(listener, Shared::new(router)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing() {
|
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(filter)
|
|
||||||
.with_target(false)
|
|
||||||
.compact()
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|||||||
+58
-2
@@ -4,13 +4,48 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
#[diesel(table_name = user_memberships)]
|
||||||
|
#[diesel(belongs_to(User, foreign_key = user_id))]
|
||||||
|
#[diesel(belongs_to(Tenant, foreign_key = tenant_id))]
|
||||||
|
pub struct UserMembership {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub role: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = user_memberships)]
|
||||||
|
pub struct NewUserMembership {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
|
#[diesel(table_name = tenants)]
|
||||||
|
#[diesel(primary_key(id))]
|
||||||
|
pub struct Tenant {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
pub storage_root: Option<String>,
|
||||||
|
pub quickwit_index: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
#[diesel(table_name = users)]
|
#[diesel(table_name = users)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub role: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -21,7 +56,6 @@ pub struct NewUser {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
pub role: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -32,6 +66,7 @@ pub struct Folder {
|
|||||||
pub parent_id: Option<Uuid>,
|
pub parent_id: Option<Uuid>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -40,6 +75,7 @@ pub struct NewFolder {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub parent_id: Option<Uuid>,
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -58,6 +94,7 @@ pub struct Document {
|
|||||||
pub issued_at: Option<NaiveDateTime>,
|
pub issued_at: Option<NaiveDateTime>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub current_version_id: Uuid,
|
pub current_version_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -72,6 +109,7 @@ pub struct NewDocument {
|
|||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub issued_at: Option<NaiveDateTime>,
|
pub issued_at: Option<NaiveDateTime>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -87,6 +125,7 @@ pub struct DocumentVersion {
|
|||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub operations_summary: serde_json::Value,
|
pub operations_summary: serde_json::Value,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -100,6 +139,7 @@ pub struct NewDocumentVersion {
|
|||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub operations_summary: serde_json::Value,
|
pub operations_summary: serde_json::Value,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -113,6 +153,7 @@ pub struct DocumentAsset {
|
|||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub cardinality: Option<i32>,
|
pub cardinality: Option<i32>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -124,6 +165,7 @@ pub struct NewDocumentAsset {
|
|||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub cardinality: Option<i32>,
|
pub cardinality: Option<i32>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -135,6 +177,7 @@ pub struct DocumentAssetObject {
|
|||||||
pub ordinal: i32,
|
pub ordinal: i32,
|
||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -145,6 +188,7 @@ pub struct NewDocumentAssetObject {
|
|||||||
pub ordinal: i32,
|
pub ordinal: i32,
|
||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -159,6 +203,7 @@ pub struct Job {
|
|||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -169,6 +214,7 @@ pub struct NewJob {
|
|||||||
pub payload: serde_json::Value,
|
pub payload: serde_json::Value,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub run_after: NaiveDateTime,
|
pub run_after: NaiveDateTime,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -178,6 +224,7 @@ pub struct Tag {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -186,6 +233,7 @@ pub struct NewTag {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -199,6 +247,7 @@ pub struct DocumentTag {
|
|||||||
pub tag_id: Uuid,
|
pub tag_id: Uuid,
|
||||||
pub assigned_at: NaiveDateTime,
|
pub assigned_at: NaiveDateTime,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -207,6 +256,7 @@ pub struct NewDocumentTag {
|
|||||||
pub document_id: Uuid,
|
pub document_id: Uuid,
|
||||||
pub tag_id: Uuid,
|
pub tag_id: Uuid,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -217,6 +267,7 @@ pub struct Correspondent {
|
|||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -225,6 +276,7 @@ pub struct NewCorrespondent {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Associations)]
|
#[derive(Debug, Clone, Queryable, Associations)]
|
||||||
@@ -238,6 +290,7 @@ pub struct DocumentCorrespondent {
|
|||||||
pub role: String,
|
pub role: String,
|
||||||
pub assigned_at: NaiveDateTime,
|
pub assigned_at: NaiveDateTime,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -247,6 +300,7 @@ pub struct NewDocumentCorrespondent {
|
|||||||
pub correspondent_id: Uuid,
|
pub correspondent_id: Uuid,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub assigned_by: Option<Uuid>,
|
pub assigned_by: Option<Uuid>,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
@@ -261,6 +315,7 @@ pub struct RefreshToken {
|
|||||||
pub revoked_at: Option<NaiveDateTime>,
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -271,4 +326,5 @@ pub struct NewRefreshToken {
|
|||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
pub issued_at: NaiveDateTime,
|
pub issued_at: NaiveDateTime,
|
||||||
pub expires_at: NaiveDateTime,
|
pub expires_at: NaiveDateTime,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
-72
@@ -1,11 +1,15 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use axum_extra::{headers::Cookie, typed_header::TypedHeader};
|
use axum_extra::{
|
||||||
|
headers::{authorization::Bearer, Authorization, Cookie},
|
||||||
|
typed_header::TypedHeader,
|
||||||
|
};
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use diesel::prelude::*;
|
use diesel::{pg::PgConnection, prelude::*};
|
||||||
use rand::{rngs::OsRng, RngCore};
|
use rand::{rngs::OsRng, RngCore};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -14,8 +18,11 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::{password, AuthenticatedUser},
|
auth::{password, AuthenticatedUser},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{NewRefreshToken, RefreshToken, User},
|
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
|
||||||
schema::{refresh_tokens, users::dsl},
|
schema::{
|
||||||
|
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||||
|
users::dsl,
|
||||||
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +34,8 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
|||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub preferred_tenant_slug: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -34,13 +43,42 @@ pub struct LoginResponse {
|
|||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
pub expires_in: i64,
|
pub expires_in: i64,
|
||||||
|
pub tenant: TenantSnippet,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantSummary {
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantSnippet {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TenantSelectionResponse {
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<LoginRequest>,
|
Json(payload): Json<LoginRequest>,
|
||||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
) -> AppResult<Response> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let user: User = dsl::users
|
let user: User = dsl::users
|
||||||
.filter(dsl::username.eq(&payload.username))
|
.filter(dsl::username.eq(&payload.username))
|
||||||
@@ -53,55 +91,67 @@ pub async fn login(
|
|||||||
return Err(AppError::unauthorized());
|
return Err(AppError::unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
let access_token = state
|
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
|
||||||
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
if memberships.is_empty() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferred_slug = payload
|
||||||
|
.preferred_tenant_slug
|
||||||
|
.as_ref()
|
||||||
|
.map(|slug| slug.trim().to_string())
|
||||||
|
.filter(|slug| !slug.is_empty());
|
||||||
|
|
||||||
|
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
|
||||||
|
memberships
|
||||||
|
.iter()
|
||||||
|
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
|
||||||
|
}) {
|
||||||
|
return issue_session(&state, &mut conn, &user, tenant.1.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if memberships.len() == 1 {
|
||||||
|
let tenant_id = memberships[0].1.id;
|
||||||
|
return issue_session(&state, &mut conn, &user, tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection_token = state
|
||||||
.jwt
|
.jwt
|
||||||
.generate_token(user.id, &user.username, &user.role)
|
.generate_tenant_selector_token(user.id)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let now = Utc::now();
|
let tenants = memberships
|
||||||
let refresh_value = generate_refresh_token();
|
.into_iter()
|
||||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
.map(|(_, tenant)| TenantSummary {
|
||||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
tenant_id: tenant.id,
|
||||||
|
slug: tenant.slug,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let new_refresh = NewRefreshToken {
|
let response = Json(TenantSelectionResponse {
|
||||||
id: Uuid::new_v4(),
|
access_token: selection_token,
|
||||||
user_id: user.id,
|
tenants,
|
||||||
token_hash: refresh_hash,
|
})
|
||||||
issued_at: now.naive_utc(),
|
.into_response();
|
||||||
expires_at: refresh_expires_at.naive_utc(),
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(refresh_tokens::table)
|
Ok(response)
|
||||||
.values(&new_refresh)
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
SET_COOKIE,
|
|
||||||
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
headers,
|
|
||||||
Json(LoginResponse {
|
|
||||||
access_token,
|
|
||||||
token_type: "Bearer".to_string(),
|
|
||||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh(
|
pub async fn refresh(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: Option<TypedHeader<Cookie>>,
|
jar: Option<TypedHeader<Cookie>>,
|
||||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
) -> AppResult<Response> {
|
||||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||||
let refresh_value = cookies
|
let refresh_value = cookies
|
||||||
.get(REFRESH_COOKIE_NAME)
|
.get(REFRESH_COOKIE_NAME)
|
||||||
.ok_or_else(AppError::unauthorized)?;
|
.ok_or_else(AppError::unauthorized)?;
|
||||||
|
|
||||||
let hashed = hash_refresh_token(refresh_value);
|
let hashed = hash_refresh_token(refresh_value);
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let now_naive = now.naive_utc();
|
let now_naive = now.naive_utc();
|
||||||
|
|
||||||
@@ -128,41 +178,39 @@ pub async fn refresh(
|
|||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let access_token = state
|
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn select_tenant(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||||
|
Json(payload): Json<TenantSelectionRequest>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let claims = state
|
||||||
.jwt
|
.jwt
|
||||||
.generate_token(user.id, &user.username, &user.role)
|
.verify_tenant_selector_token(bearer.token())
|
||||||
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let membership_exists = memberships_dsl::user_memberships
|
||||||
|
.filter(memberships_dsl::user_id.eq(claims.sub))
|
||||||
|
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||||
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.select(memberships_dsl::id)
|
||||||
|
.first::<Uuid>(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
if membership_exists.is_none() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: User = dsl::users
|
||||||
|
.find(claims.sub)
|
||||||
|
.first(&mut conn)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let new_refresh_value = generate_refresh_token();
|
issue_session(&state, &mut conn, &user, payload.tenant_id)
|
||||||
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
|
|
||||||
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
|
||||||
|
|
||||||
let new_refresh = NewRefreshToken {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
user_id: user.id,
|
|
||||||
token_hash: new_refresh_hash,
|
|
||||||
issued_at: now_naive,
|
|
||||||
expires_at: new_refresh_expires.naive_utc(),
|
|
||||||
};
|
|
||||||
|
|
||||||
diesel::insert_into(refresh_tokens::table)
|
|
||||||
.values(&new_refresh)
|
|
||||||
.execute(&mut conn)?;
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
SET_COOKIE,
|
|
||||||
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
headers,
|
|
||||||
Json(LoginResponse {
|
|
||||||
access_token,
|
|
||||||
token_type: "Bearer".to_string(),
|
|
||||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(
|
pub async fn logout(
|
||||||
@@ -170,7 +218,7 @@ pub async fn logout(
|
|||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
jar: Option<TypedHeader<Cookie>>,
|
jar: Option<TypedHeader<Cookie>>,
|
||||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let mut rows_affected = 0;
|
let mut rows_affected = 0;
|
||||||
|
|
||||||
@@ -214,6 +262,92 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
|||||||
Json(user)
|
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,
|
||||||
|
user: &User,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let access_token = state
|
||||||
|
.jwt
|
||||||
|
.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);
|
||||||
|
|
||||||
|
let new_refresh = NewRefreshToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
token_hash: refresh_hash,
|
||||||
|
issued_at: now.naive_utc(),
|
||||||
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
|
tenant_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(refresh_tokens::table)
|
||||||
|
.values(&new_refresh)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
let mut response = Json(LoginResponse {
|
||||||
|
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();
|
||||||
|
|
||||||
|
response.headers_mut().insert(
|
||||||
|
SET_COOKIE,
|
||||||
|
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
fn hash_refresh_token(token: &str) -> String {
|
fn hash_refresh_token(token: &str) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(token.as_bytes());
|
hasher.update(token.as_bytes());
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
use axum::{
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
response::IntoResponse,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -13,14 +8,16 @@ use serde_json::Value;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
auth::TenantScopedConn,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{Correspondent, NewCorrespondent},
|
models::{Correspondent, NewCorrespondent},
|
||||||
schema::{correspondents, document_correspondents},
|
schema::{correspondents, document_correspondents},
|
||||||
state::AppState,
|
utils::{
|
||||||
|
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||||
|
time::to_iso,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::documents::to_iso;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct CorrespondentUsage {
|
pub struct CorrespondentUsage {
|
||||||
pub total: i64,
|
pub total: i64,
|
||||||
@@ -59,15 +56,19 @@ struct CorrespondentChangeset<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_correspondents(
|
pub async fn list_correspondents(
|
||||||
State(state): State<AppState>,
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
|
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
|
||||||
let mut conn = state.db()?;
|
|
||||||
|
|
||||||
let correspondents_list: Vec<Correspondent> = correspondents::table
|
let correspondents_list: Vec<Correspondent> = correspondents::table
|
||||||
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.order(correspondents::name.asc())
|
.order(correspondents::name.asc())
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
||||||
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.group_by((
|
.group_by((
|
||||||
document_correspondents::correspondent_id,
|
document_correspondents::correspondent_id,
|
||||||
document_correspondents::role,
|
document_correspondents::role,
|
||||||
@@ -93,11 +94,15 @@ pub async fn list_correspondents(
|
|||||||
response.push(build_summary(correspondent, role_counts));
|
response.push(build_summary(correspondent, role_counts));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(response))
|
response.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_correspondent(
|
pub async fn create_correspondent(
|
||||||
State(state): State<AppState>,
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateCorrespondentRequest>,
|
Json(payload): Json<CreateCorrespondentRequest>,
|
||||||
) -> AppResult<Json<CorrespondentSummary>> {
|
) -> AppResult<Json<CorrespondentSummary>> {
|
||||||
let name = payload.name.trim();
|
let name = payload.name.trim();
|
||||||
@@ -111,9 +116,9 @@ pub async fn create_correspondent(
|
|||||||
id: new_id,
|
id: new_id,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
metadata: metadata_value,
|
metadata: metadata_value,
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut conn = state.db()?;
|
|
||||||
match diesel::insert_into(correspondents::table)
|
match diesel::insert_into(correspondents::table)
|
||||||
.values(&new_correspondent)
|
.values(&new_correspondent)
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
@@ -125,19 +130,29 @@ pub async fn create_correspondent(
|
|||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
}
|
}
|
||||||
|
|
||||||
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?;
|
let correspondent: Correspondent = correspondents::table
|
||||||
Ok(Json(build_summary(correspondent, BTreeMap::new())))
|
.find(new_id)
|
||||||
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)
|
||||||
|
.one()?;
|
||||||
|
|
||||||
|
build_summary(correspondent, BTreeMap::new()).into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_correspondent(
|
pub async fn update_correspondent(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(correspondent_id): Path<Uuid>,
|
Path(correspondent_id): Path<Uuid>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<UpdateCorrespondentRequest>,
|
Json(payload): Json<UpdateCorrespondentRequest>,
|
||||||
) -> AppResult<Json<CorrespondentSummary>> {
|
) -> AppResult<Json<CorrespondentSummary>> {
|
||||||
let mut conn = state.db()?;
|
|
||||||
let existing: Correspondent = correspondents::table
|
let existing: Correspondent = correspondents::table
|
||||||
.find(correspondent_id)
|
.find(correspondent_id)
|
||||||
.first(&mut conn)?;
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)
|
||||||
|
.one()?;
|
||||||
|
|
||||||
let mut new_name: Option<String> = None;
|
let mut new_name: Option<String> = None;
|
||||||
if let Some(ref candidate) = payload.name {
|
if let Some(ref candidate) = payload.name {
|
||||||
@@ -149,6 +164,7 @@ pub async fn update_correspondent(
|
|||||||
let duplicate = correspondents::table
|
let duplicate = correspondents::table
|
||||||
.filter(correspondents::name.eq(trimmed))
|
.filter(correspondents::name.eq(trimmed))
|
||||||
.filter(correspondents::id.ne(correspondent_id))
|
.filter(correspondents::id.ne(correspondent_id))
|
||||||
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
.first::<Correspondent>(&mut conn)
|
.first::<Correspondent>(&mut conn)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
if duplicate.is_some() {
|
if duplicate.is_some() {
|
||||||
@@ -167,8 +183,8 @@ pub async fn update_correspondent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if new_name.is_none() && new_metadata.is_none() {
|
if new_name.is_none() && new_metadata.is_none() {
|
||||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||||
return Ok(Json(build_summary(existing.clone(), usage)));
|
return build_summary(existing.clone(), usage).into_json();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut changeset = CorrespondentChangeset::default();
|
let mut changeset = CorrespondentChangeset::default();
|
||||||
@@ -180,24 +196,33 @@ pub async fn update_correspondent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
diesel::update(correspondents::table.find(correspondent_id))
|
diesel::update(
|
||||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
correspondents::table
|
||||||
.execute(&mut conn)?;
|
.find(correspondent_id)
|
||||||
|
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.set((&changeset, correspondents::updated_at.eq(now)))
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
let updated: Correspondent = correspondents::table
|
let updated: Correspondent = correspondents::table
|
||||||
.find(correspondent_id)
|
.find(correspondent_id)
|
||||||
.first(&mut conn)?;
|
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
.first(&mut conn)
|
||||||
Ok(Json(build_summary(updated, usage)))
|
.one()?;
|
||||||
|
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||||
|
build_summary(updated, usage).into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_correspondent(
|
pub async fn delete_correspondent(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(correspondent_id): Path<Uuid>,
|
Path(correspondent_id): Path<Uuid>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
TenantScopedConn {
|
||||||
let mut conn = state.db()?;
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
let usage: i64 = document_correspondents::table
|
let usage: i64 = document_correspondents::table
|
||||||
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
@@ -208,12 +233,16 @@ pub async fn delete_correspondent(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let deleted =
|
let deleted = diesel::delete(
|
||||||
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
|
correspondents::table
|
||||||
|
.filter(correspondents::id.eq(correspondent_id))
|
||||||
|
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)?;
|
||||||
if deleted == 0 {
|
if deleted == 0 {
|
||||||
return Err(AppError::not_found());
|
return Err(AppError::not_found());
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_summary(
|
fn build_summary(
|
||||||
@@ -243,10 +272,12 @@ fn normalize_metadata(input: Option<Value>) -> Value {
|
|||||||
|
|
||||||
fn load_usage_for_correspondent(
|
fn load_usage_for_correspondent(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
correspondent_id: Uuid,
|
correspondent_id: Uuid,
|
||||||
) -> AppResult<BTreeMap<String, i64>> {
|
) -> AppResult<BTreeMap<String, i64>> {
|
||||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
let rows: Vec<(String, i64)> = document_correspondents::table
|
||||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||||
|
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||||
.group_by(document_correspondents::role)
|
.group_by(document_correspondents::role)
|
||||||
.select((document_correspondents::role, count_star()))
|
.select((document_correspondents::role, count_star()))
|
||||||
.load(conn)?;
|
.load(conn)?;
|
||||||
|
|||||||
+872
-620
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
+222
-72
@@ -4,19 +4,24 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{Document, Folder, NewFolder};
|
use crate::models::{Document, Folder, NewFolder};
|
||||||
use crate::schema::{documents, folders};
|
use crate::schema::{documents, folders};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthenticatedUser,
|
auth::TenantScopedConn,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::documents::{
|
use super::documents::{
|
||||||
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
||||||
to_document_response, to_iso, DocumentResponse,
|
to_document_response, DocumentResponse,
|
||||||
|
};
|
||||||
|
use crate::utils::{
|
||||||
|
json::{classify_nullable, NullableValue},
|
||||||
|
time::to_iso,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -31,13 +36,6 @@ pub struct EnsureFolderPathRequest {
|
|||||||
pub segments: Vec<String>,
|
pub segments: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct UpdateFolderRequest {
|
|
||||||
#[serde(default)]
|
|
||||||
pub parent_id: Option<Option<Uuid>>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct FolderResponse {
|
pub struct FolderResponse {
|
||||||
pub folder: FolderInfo,
|
pub folder: FolderInfo,
|
||||||
@@ -69,16 +67,36 @@ pub struct FolderInfo {
|
|||||||
pub updated_at: String,
|
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(
|
pub async fn ensure_folder_path(
|
||||||
State(state): State<AppState>,
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<EnsureFolderPathRequest>,
|
Json(payload): Json<EnsureFolderPathRequest>,
|
||||||
) -> AppResult<Json<FolderResponse>> {
|
) -> AppResult<Json<FolderResponse>> {
|
||||||
if payload.segments.is_empty() {
|
if payload.segments.is_empty() {
|
||||||
return Err(AppError::bad_request("segments must not be empty"));
|
return Err(AppError::bad_request("segments must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = state.db()?;
|
|
||||||
|
|
||||||
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
||||||
let mut current_parent = payload.parent_id;
|
let mut current_parent = payload.parent_id;
|
||||||
let mut last_folder: Option<Folder> = None;
|
let mut last_folder: Option<Folder> = None;
|
||||||
@@ -91,12 +109,14 @@ pub async fn ensure_folder_path(
|
|||||||
|
|
||||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||||
folders::table
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
.filter(folders::name.eq(name))
|
.filter(folders::name.eq(name))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
} else {
|
} else {
|
||||||
folders::table
|
folders::table
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.filter(folders::parent_id.is_null())
|
.filter(folders::parent_id.is_null())
|
||||||
.filter(folders::name.eq(name))
|
.filter(folders::name.eq(name))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
@@ -110,15 +130,35 @@ pub async fn ensure_folder_path(
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
parent_id: current_parent,
|
parent_id: current_parent,
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(folders::table)
|
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||||
.values(&new_folder)
|
.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);
|
current_parent = Some(folder.id);
|
||||||
last_folder = Some(folder);
|
last_folder = Some(folder);
|
||||||
}
|
}
|
||||||
@@ -132,26 +172,72 @@ pub async fn ensure_folder_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_folder(
|
pub async fn create_folder(
|
||||||
State(state): State<AppState>,
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateFolderRequest>,
|
Json(payload): Json<CreateFolderRequest>,
|
||||||
) -> AppResult<Json<FolderResponse>> {
|
) -> AppResult<Json<FolderResponse>> {
|
||||||
if payload.name.trim().is_empty() {
|
if payload.name.trim().is_empty() {
|
||||||
return Err(AppError::bad_request("name must not be empty"));
|
return Err(AppError::bad_request("name must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = state.db()?;
|
let name = payload.name.trim();
|
||||||
|
|
||||||
let new_folder = NewFolder {
|
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||||
id: Uuid::new_v4(),
|
folders::table
|
||||||
name: payload.name.trim().to_string(),
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
parent_id: payload.parent_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)
|
let folder: Folder = if let Some(folder) = existing {
|
||||||
.values(&new_folder)
|
folder
|
||||||
.execute(&mut conn)?;
|
} 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 {
|
Ok(Json(FolderResponse {
|
||||||
folder: folder_to_info(folder),
|
folder: folder_to_info(folder),
|
||||||
}))
|
}))
|
||||||
@@ -161,10 +247,13 @@ pub async fn list_folder_contents(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(folder_identifier): Path<String>,
|
Path(folder_identifier): Path<String>,
|
||||||
Query(query): Query<FolderContentsQuery>,
|
Query(query): Query<FolderContentsQuery>,
|
||||||
user: AuthenticatedUser,
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
) -> AppResult<Json<FolderContentsResponse>> {
|
) -> AppResult<Json<FolderContentsResponse>> {
|
||||||
let mut conn = state.db()?;
|
|
||||||
|
|
||||||
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -176,7 +265,10 @@ pub async fn list_folder_contents(
|
|||||||
|
|
||||||
let folder = match folder_id {
|
let folder = match folder_id {
|
||||||
Some(id) => Some(folder_to_info(
|
Some(id) => Some(folder_to_info(
|
||||||
folders::table.find(id).first::<Folder>(&mut conn)?,
|
folders::table
|
||||||
|
.find(id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first::<Folder>(&mut conn)?,
|
||||||
)),
|
)),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
@@ -184,11 +276,13 @@ pub async fn list_folder_contents(
|
|||||||
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||||
folders::table
|
folders::table
|
||||||
.filter(folders::parent_id.eq(parent_id))
|
.filter(folders::parent_id.eq(parent_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.order(folders::name.asc())
|
.order(folders::name.asc())
|
||||||
.load(&mut conn)?
|
.load(&mut conn)?
|
||||||
} else {
|
} else {
|
||||||
folders::table
|
folders::table
|
||||||
.filter(folders::parent_id.is_null())
|
.filter(folders::parent_id.is_null())
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.order(folders::name.asc())
|
.order(folders::name.asc())
|
||||||
.load(&mut conn)?
|
.load(&mut conn)?
|
||||||
};
|
};
|
||||||
@@ -197,6 +291,7 @@ pub async fn list_folder_contents(
|
|||||||
let documents = if query.include_documents {
|
let documents = if query.include_documents {
|
||||||
let docs_query = documents::table
|
let docs_query = documents::table
|
||||||
.filter(documents::deleted_at.is_null())
|
.filter(documents::deleted_at.is_null())
|
||||||
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
.order(documents::uploaded_at.desc());
|
.order(documents::uploaded_at.desc());
|
||||||
|
|
||||||
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
||||||
@@ -214,7 +309,7 @@ pub async fn list_folder_contents(
|
|||||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
|
||||||
|
|
||||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
@@ -223,7 +318,7 @@ pub async fn list_folder_contents(
|
|||||||
let current_version = primary_versions.get(&doc.id).cloned();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
documents.push(to_document_response(
|
documents.push(to_document_response(
|
||||||
&state,
|
&state,
|
||||||
user.user_id,
|
user_id,
|
||||||
doc,
|
doc,
|
||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
@@ -244,16 +339,23 @@ pub async fn list_folder_contents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_folder(
|
pub async fn delete_folder(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(folder_id): Path<Uuid>,
|
Path(folder_id): Path<Uuid>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
) -> AppResult<StatusCode> {
|
) -> AppResult<StatusCode> {
|
||||||
let mut conn = state.db()?;
|
|
||||||
|
|
||||||
conn.transaction::<_, AppError, _>(|conn| {
|
conn.transaction::<_, AppError, _>(|conn| {
|
||||||
folders::table.find(folder_id).first::<Folder>(conn)?;
|
folders::table
|
||||||
|
.find(folder_id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first::<Folder>(conn)?;
|
||||||
|
|
||||||
let has_child_folders: bool = diesel::select(exists(
|
let has_child_folders: bool = diesel::select(exists(
|
||||||
folders::table.filter(folders::parent_id.eq(Some(folder_id))),
|
folders::table
|
||||||
|
.filter(folders::parent_id.eq(Some(folder_id)))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id)),
|
||||||
))
|
))
|
||||||
.get_result(conn)?;
|
.get_result(conn)?;
|
||||||
|
|
||||||
@@ -266,6 +368,7 @@ pub async fn delete_folder(
|
|||||||
let has_documents: bool = diesel::select(exists(
|
let has_documents: bool = diesel::select(exists(
|
||||||
documents::table
|
documents::table
|
||||||
.filter(documents::folder_id.eq(Some(folder_id)))
|
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||||
|
.filter(documents::tenant_id.eq(tenant_id))
|
||||||
.filter(documents::deleted_at.is_null()),
|
.filter(documents::deleted_at.is_null()),
|
||||||
))
|
))
|
||||||
.get_result(conn)?;
|
.get_result(conn)?;
|
||||||
@@ -276,7 +379,12 @@ pub async fn delete_folder(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::delete(folders::table.find(folder_id)).execute(conn)?;
|
diesel::delete(
|
||||||
|
folders::table
|
||||||
|
.filter(folders::id.eq(folder_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
@@ -285,50 +393,84 @@ pub async fn delete_folder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_folder(
|
pub async fn update_folder(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(folder_id): Path<Uuid>,
|
Path(folder_id): Path<Uuid>,
|
||||||
Json(payload): Json<UpdateFolderRequest>,
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Json(body): Json<Value>,
|
||||||
) -> AppResult<StatusCode> {
|
) -> AppResult<StatusCode> {
|
||||||
let mut conn = state.db()?;
|
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| {
|
conn.transaction::<(), AppError, _>(|conn| {
|
||||||
let folder: Folder = folders::table.find(folder_id).first(conn)?;
|
let folder: Folder = folders::table
|
||||||
|
.find(folder_id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
let mut next_parent = folder.parent_id;
|
let mut next_parent = folder.parent_id;
|
||||||
let mut parent_changed = false;
|
let mut parent_changed = false;
|
||||||
|
match parent_class {
|
||||||
if let Some(parent_request) = payload.parent_id {
|
NullableValue::Omitted => {}
|
||||||
if parent_request == Some(folder_id) {
|
NullableValue::Null => {
|
||||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
if folder.parent_id.is_some() {
|
||||||
}
|
parent_changed = true;
|
||||||
|
|
||||||
if let Some(parent_id) = parent_request {
|
|
||||||
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
|
|
||||||
|
|
||||||
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
|
|
||||||
if descendant_ids.contains(&parent_id) {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"cannot move folder into itself or a descendant",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
parent_changed = parent_request != folder.parent_id;
|
let _parent: Folder = folders::table
|
||||||
next_parent = parent_request;
|
.find(parent_id)
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
|
.first(conn)?;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
next_parent = Some(parent_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut new_name = folder.name.clone();
|
let mut new_name = folder.name.clone();
|
||||||
let mut name_changed = false;
|
let mut name_changed = false;
|
||||||
|
match name_class {
|
||||||
if let Some(name) = payload.name {
|
NullableValue::Omitted => {}
|
||||||
let trimmed = name.trim();
|
NullableValue::Null => {
|
||||||
if trimmed.is_empty() {
|
return Err(AppError::bad_request("name cannot be null"));
|
||||||
return Err(AppError::bad_request("name must not be empty"));
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if trimmed != folder.name {
|
||||||
new_name = trimmed.to_string();
|
new_name = trimmed.to_string();
|
||||||
name_changed = true;
|
name_changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +483,7 @@ pub async fn update_folder(
|
|||||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||||
.filter(folders::name.eq(&new_name))
|
.filter(folders::name.eq(&new_name))
|
||||||
.filter(folders::id.ne(folder_id))
|
.filter(folders::id.ne(folder_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.first::<Folder>(conn)
|
.first::<Folder>(conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
} else {
|
} else {
|
||||||
@@ -348,6 +491,7 @@ pub async fn update_folder(
|
|||||||
.filter(folders::parent_id.is_null())
|
.filter(folders::parent_id.is_null())
|
||||||
.filter(folders::name.eq(&new_name))
|
.filter(folders::name.eq(&new_name))
|
||||||
.filter(folders::id.ne(folder_id))
|
.filter(folders::id.ne(folder_id))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.first::<Folder>(conn)
|
.first::<Folder>(conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
};
|
};
|
||||||
@@ -358,12 +502,16 @@ pub async fn update_folder(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::update(folders::table.find(folder_id))
|
diesel::update(
|
||||||
.set((
|
folders::table
|
||||||
folders::parent_id.eq(next_parent),
|
.find(folder_id)
|
||||||
folders::name.eq(&new_name),
|
.filter(folders::tenant_id.eq(tenant_id)),
|
||||||
))
|
)
|
||||||
.execute(conn)?;
|
.set((
|
||||||
|
folders::parent_id.eq(next_parent),
|
||||||
|
folders::name.eq(&new_name),
|
||||||
|
))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
@@ -383,6 +531,7 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
|
|||||||
|
|
||||||
pub(super) fn gather_descendant_folder_ids(
|
pub(super) fn gather_descendant_folder_ids(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
folder_id: Uuid,
|
folder_id: Uuid,
|
||||||
) -> AppResult<Vec<Uuid>> {
|
) -> AppResult<Vec<Uuid>> {
|
||||||
let mut ids = vec![folder_id];
|
let mut ids = vec![folder_id];
|
||||||
@@ -391,6 +540,7 @@ pub(super) fn gather_descendant_folder_ids(
|
|||||||
while let Some(current) = queue.pop() {
|
while let Some(current) = queue.pop() {
|
||||||
let child_ids: Vec<Uuid> = folders::table
|
let child_ids: Vec<Uuid> = folders::table
|
||||||
.filter(folders::parent_id.eq(Some(current)))
|
.filter(folders::parent_id.eq(Some(current)))
|
||||||
|
.filter(folders::tenant_id.eq(tenant_id))
|
||||||
.select(folders::id)
|
.select(folders::id)
|
||||||
.load(conn)?;
|
.load(conn)?;
|
||||||
queue.extend(child_ids.iter().copied());
|
queue.extend(child_ids.iter().copied());
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ use axum::http::HeaderValue;
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
middleware,
|
middleware,
|
||||||
|
response::Json,
|
||||||
routing::{delete, get, patch, post},
|
routing::{delete, get, patch, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
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 auth;
|
||||||
pub mod correspondents;
|
pub mod correspondents;
|
||||||
@@ -50,14 +53,16 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
|
.route("/tenants", get(auth::list_tenants))
|
||||||
.route("/me", get(auth::me));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
|
.route("/check", get(documents::check_document))
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
get(documents::list_documents).post(documents::upload_document),
|
get(documents::list_documents).post(documents::upload_document),
|
||||||
)
|
)
|
||||||
.route("/reanalyze", post(documents::reanalyze_all_documents))
|
|
||||||
.route("/bulk/move", post(documents::bulk_move_documents))
|
.route("/bulk/move", post(documents::bulk_move_documents))
|
||||||
.route("/bulk/tags", post(documents::bulk_update_tags))
|
.route("/bulk/tags", post(documents::bulk_update_tags))
|
||||||
.route(
|
.route(
|
||||||
@@ -97,10 +102,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
let folders_routes = Router::new()
|
let folders_routes = Router::new()
|
||||||
.route("/", post(folders::create_folder))
|
.route("/", post(folders::create_folder))
|
||||||
.route("/path", post(folders::ensure_folder_path))
|
.route("/path", post(folders::ensure_folder_path))
|
||||||
.route(
|
.route("/:id", get(folders::get_folder))
|
||||||
"/:id",
|
.route("/:id", delete(folders::delete_folder))
|
||||||
delete(folders::delete_folder).patch(folders::update_folder),
|
.route("/:id", patch(folders::update_folder))
|
||||||
)
|
|
||||||
.route("/:id/contents", get(folders::list_folder_contents));
|
.route("/:id/contents", get(folders::list_folder_contents));
|
||||||
|
|
||||||
let tags_routes = Router::new()
|
let tags_routes = Router::new()
|
||||||
@@ -129,9 +133,22 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.nest("/api/assets", assets_routes)
|
.nest("/api/assets", assets_routes)
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.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()
|
Router::new()
|
||||||
.merge(download_routes)
|
.merge(download_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
|
.merge(docs_route)
|
||||||
.nest("/api/auth", auth_routes)
|
.nest("/api/auth", auth_routes)
|
||||||
.route("/api/health", get(health::health_check))
|
.route("/api/health", get(health::health_check))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
+77
-34
@@ -1,19 +1,16 @@
|
|||||||
use crate::utils::json::{classify_nullable, NullableValue};
|
use crate::utils::json::{classify_nullable, NullableValue};
|
||||||
use axum::{
|
use axum::{extract::Path, http::StatusCode, Json};
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use diesel::{dsl::count_star, prelude::*};
|
use diesel::{dsl::count_star, prelude::*};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::TenantScopedConn;
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{NewTag, Tag};
|
use crate::models::{NewTag, Tag};
|
||||||
use crate::schema::{document_tags, tags};
|
use crate::schema::{document_tags, tags};
|
||||||
use crate::state::AppState;
|
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateTagRequest {
|
pub struct CreateTagRequest {
|
||||||
@@ -36,19 +33,27 @@ pub struct TagCatalogEntry {
|
|||||||
pub usage_count: i64,
|
pub usage_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
pub async fn list_tags(
|
||||||
let mut conn = state.db()?;
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||||
|
let tag_list: Vec<Tag> = tags::table
|
||||||
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
|
.order(tags::label.asc())
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
|
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
|
||||||
|
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||||
.group_by(document_tags::tag_id)
|
.group_by(document_tags::tag_id)
|
||||||
.select((document_tags::tag_id, count_star()))
|
.select((document_tags::tag_id, count_star()))
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||||
|
|
||||||
let response = tag_list
|
let response: Vec<TagCatalogEntry> = tag_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|tag| TagCatalogEntry {
|
.map(|tag| TagCatalogEntry {
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
@@ -58,22 +63,26 @@ pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagC
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(response))
|
response.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_tag(
|
pub async fn create_tag(
|
||||||
State(state): State<AppState>,
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateTagRequest>,
|
Json(payload): Json<CreateTagRequest>,
|
||||||
) -> AppResult<Json<TagCatalogEntry>> {
|
) -> AppResult<Json<TagCatalogEntry>> {
|
||||||
if payload.label.trim().is_empty() {
|
if payload.label.trim().is_empty() {
|
||||||
return Err(AppError::bad_request("label must not be empty"));
|
return Err(AppError::bad_request("label must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = state.db()?;
|
|
||||||
let new_tag = NewTag {
|
let new_tag = NewTag {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
label: payload.label.trim().to_string(),
|
label: payload.label.trim().to_string(),
|
||||||
color: payload.color,
|
color: payload.color,
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
match diesel::insert_into(tags::table)
|
match diesel::insert_into(tags::table)
|
||||||
@@ -90,22 +99,35 @@ pub async fn create_tag(
|
|||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
}
|
}
|
||||||
|
|
||||||
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
|
let tag: Tag = tags::table
|
||||||
Ok(Json(TagCatalogEntry {
|
.find(new_tag.id)
|
||||||
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)
|
||||||
|
.one()?;
|
||||||
|
|
||||||
|
TagCatalogEntry {
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
label: tag.label,
|
label: tag.label,
|
||||||
color: tag.color,
|
color: tag.color,
|
||||||
usage_count: 0,
|
usage_count: 0,
|
||||||
}))
|
}
|
||||||
|
.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_tag(
|
pub async fn update_tag(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(tag_id): Path<Uuid>,
|
Path(tag_id): Path<Uuid>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> AppResult<Json<TagCatalogEntry>> {
|
) -> AppResult<Json<TagCatalogEntry>> {
|
||||||
let mut conn = state.db()?;
|
let existing: Tag = tags::table
|
||||||
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
.find(tag_id)
|
||||||
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)
|
||||||
|
.one()?;
|
||||||
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
|
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
|
||||||
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
|
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
|
||||||
|
|
||||||
@@ -116,12 +138,13 @@ pub async fn update_tag(
|
|||||||
.filter(document_tags::tag_id.eq(tag_id))
|
.filter(document_tags::tag_id.eq(tag_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
return Ok(Json(TagCatalogEntry {
|
return TagCatalogEntry {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
label: existing.label.clone(),
|
label: existing.label.clone(),
|
||||||
color: existing.color.clone(),
|
color: existing.color.clone(),
|
||||||
usage_count,
|
usage_count,
|
||||||
}));
|
}
|
||||||
|
.into_json();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut new_label: Option<String> = None;
|
let mut new_label: Option<String> = None;
|
||||||
@@ -140,6 +163,7 @@ pub async fn update_tag(
|
|||||||
let duplicate = tags::table
|
let duplicate = tags::table
|
||||||
.filter(tags::label.eq(trimmed))
|
.filter(tags::label.eq(trimmed))
|
||||||
.filter(tags::id.ne(tag_id))
|
.filter(tags::id.ne(tag_id))
|
||||||
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
.first::<Tag>(&mut conn)
|
.first::<Tag>(&mut conn)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
if duplicate.is_some() {
|
if duplicate.is_some() {
|
||||||
@@ -174,6 +198,7 @@ pub async fn update_tag(
|
|||||||
if !label_changed && !color_changed {
|
if !label_changed && !color_changed {
|
||||||
let usage_count: i64 = document_tags::table
|
let usage_count: i64 = document_tags::table
|
||||||
.filter(document_tags::tag_id.eq(tag_id))
|
.filter(document_tags::tag_id.eq(tag_id))
|
||||||
|
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
return Ok(Json(TagCatalogEntry {
|
return Ok(Json(TagCatalogEntry {
|
||||||
@@ -191,32 +216,45 @@ pub async fn update_tag(
|
|||||||
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::update(tags::table.find(tag_id))
|
diesel::update(
|
||||||
.set(&changeset)
|
tags::table
|
||||||
.execute(&mut conn)?;
|
.find(tag_id)
|
||||||
|
.filter(tags::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.set(&changeset)
|
||||||
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
let updated: Tag = tags::table
|
||||||
|
.find(tag_id)
|
||||||
|
.filter(tags::tenant_id.eq(tenant_id))
|
||||||
|
.first(&mut conn)
|
||||||
|
.one()?;
|
||||||
let usage_count: i64 = document_tags::table
|
let usage_count: i64 = document_tags::table
|
||||||
.filter(document_tags::tag_id.eq(tag_id))
|
.filter(document_tags::tag_id.eq(tag_id))
|
||||||
|
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
Ok(Json(TagCatalogEntry {
|
TagCatalogEntry {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
label: updated.label,
|
label: updated.label,
|
||||||
color: updated.color,
|
color: updated.color,
|
||||||
usage_count,
|
usage_count,
|
||||||
}))
|
}
|
||||||
|
.into_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_tag(
|
pub async fn delete_tag(
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(tag_id): Path<Uuid>,
|
Path(tag_id): Path<Uuid>,
|
||||||
) -> AppResult<impl axum::response::IntoResponse> {
|
TenantScopedConn {
|
||||||
let mut conn = state.db()?;
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
let usage: i64 = document_tags::table
|
let usage: i64 = document_tags::table
|
||||||
.filter(document_tags::tag_id.eq(tag_id))
|
.filter(document_tags::tag_id.eq(tag_id))
|
||||||
|
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||||
.select(count_star())
|
.select(count_star())
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
@@ -226,10 +264,15 @@ pub async fn delete_tag(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
|
let deleted = diesel::delete(
|
||||||
|
tags::table
|
||||||
|
.find(tag_id)
|
||||||
|
.filter(tags::tenant_id.eq(tenant_id)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)?;
|
||||||
if deleted == 0 {
|
if deleted == 0 {
|
||||||
return Err(AppError::not_found());
|
return Err(AppError::not_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
no_content()
|
||||||
}
|
}
|
||||||
|
|||||||
+202
-121
@@ -20,17 +20,26 @@ use crate::error::{AppError, AppResult};
|
|||||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
use crate::models::{Document, DocumentVersion, Folder, User};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||||
folders::dsl as folders_dsl, users::dsl as users_dsl,
|
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl,
|
||||||
|
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use crate::utils::{http::inline_content_disposition, time::to_http_date};
|
||||||
|
|
||||||
const REALM: &str = "Papercrate WebDAV";
|
const REALM: &str = "Papercrate WebDAV";
|
||||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct WebDavUser {
|
struct TenantEntry {
|
||||||
|
tenant_id: Uuid,
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct WebDavContext {
|
||||||
_user_id: Uuid,
|
_user_id: Uuid,
|
||||||
_username: String,
|
_username: String,
|
||||||
|
tenants: Vec<TenantEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_router() -> Router<AppState> {
|
pub fn create_router() -> Router<AppState> {
|
||||||
@@ -68,7 +77,7 @@ async fn handle_propfind(
|
|||||||
path: &str,
|
path: &str,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let _user = match authenticate(state, &headers)? {
|
let context = match authenticate(state, &headers)? {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Ok(unauthorized_response()),
|
None => return Ok(unauthorized_response()),
|
||||||
};
|
};
|
||||||
@@ -79,25 +88,44 @@ async fn handle_propfind(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
let resolution = match resolve_path(state, &segments)? {
|
|
||||||
Some(resolved) => resolved,
|
|
||||||
None => return Ok(not_found_response()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let resources = match resolution {
|
let resources = if segments.is_empty() {
|
||||||
ResolvedPath::Root => {
|
build_account_root_resources(&context.tenants, depth)
|
||||||
let contents = fetch_folder_contents(state, None)?;
|
} else {
|
||||||
build_resources_for_folder(None, &[], &contents, depth)
|
let (requested_slug, remainder) = segments.split_first().unwrap();
|
||||||
|
let tenant_entry = match context
|
||||||
|
.tenants
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
||||||
|
{
|
||||||
|
Some(entry) => TenantEntry {
|
||||||
|
tenant_id: entry.tenant_id,
|
||||||
|
slug: entry.slug.clone(),
|
||||||
|
},
|
||||||
|
None => return Ok(not_found_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
||||||
|
Some(resolved) => resolved,
|
||||||
|
None => return Ok(not_found_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match resolution {
|
||||||
|
ResolvedPath::TenantRoot { chain } => {
|
||||||
|
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
|
||||||
|
build_resources_for_folder(None, &chain, &contents, depth)
|
||||||
|
}
|
||||||
|
ResolvedPath::Folder { folder, chain } => {
|
||||||
|
let contents =
|
||||||
|
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
|
||||||
|
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
||||||
|
}
|
||||||
|
ResolvedPath::Document {
|
||||||
|
document,
|
||||||
|
version,
|
||||||
|
chain,
|
||||||
|
} => build_resources_for_document(&chain, &document, &version),
|
||||||
}
|
}
|
||||||
ResolvedPath::Folder { folder, chain } => {
|
|
||||||
let contents = fetch_folder_contents(state, Some(folder.id))?;
|
|
||||||
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
|
|
||||||
}
|
|
||||||
ResolvedPath::Document {
|
|
||||||
document,
|
|
||||||
version,
|
|
||||||
chain,
|
|
||||||
} => build_resources_for_document(&chain, &document, &version),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = render_multistatus(&resources)
|
let body = render_multistatus(&resources)
|
||||||
@@ -118,13 +146,34 @@ async fn handle_get_or_head(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
method: Method,
|
method: Method,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let _user = match authenticate(state, &headers)? {
|
let context = match authenticate(state, &headers)? {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => return Ok(unauthorized_response()),
|
None => return Ok(unauthorized_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let segments = parse_segments(path)?;
|
let segments = parse_segments(path)?;
|
||||||
let resolution = match resolve_path(state, &segments)? {
|
let (requested_slug, remainder) = match segments.split_first() {
|
||||||
|
Some(values) => values,
|
||||||
|
None => return Ok(method_not_allowed()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tenant_entry = match context
|
||||||
|
.tenants
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
|
||||||
|
{
|
||||||
|
Some(entry) => TenantEntry {
|
||||||
|
tenant_id: entry.tenant_id,
|
||||||
|
slug: entry.slug.clone(),
|
||||||
|
},
|
||||||
|
None => return Ok(not_found_response()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if remainder.is_empty() {
|
||||||
|
return Ok(method_not_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => resolved,
|
||||||
None => return Ok(not_found_response()),
|
None => return Ok(not_found_response()),
|
||||||
};
|
};
|
||||||
@@ -219,21 +268,29 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
|||||||
|
|
||||||
fn fetch_folder_contents(
|
fn fetch_folder_contents(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
tenant_id: Uuid,
|
||||||
folder_id: Option<Uuid>,
|
folder_id: Option<Uuid>,
|
||||||
) -> AppResult<WebDavFolderContents> {
|
) -> AppResult<WebDavFolderContents> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db_for_tenant(tenant_id)?;
|
||||||
|
|
||||||
let folder = match folder_id {
|
let folder = match folder_id {
|
||||||
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
|
Some(id) => Some(
|
||||||
|
folders_dsl::folders
|
||||||
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
|
.find(id)
|
||||||
|
.first::<Folder>(&mut conn)?,
|
||||||
|
),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let subfolders: Vec<Folder> = match folder_id {
|
let subfolders: Vec<Folder> = match folder_id {
|
||||||
Some(id) => folders_dsl::folders
|
Some(id) => folders_dsl::folders
|
||||||
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
.filter(folders_dsl::parent_id.eq(Some(id)))
|
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||||
.order(folders_dsl::name.asc())
|
.order(folders_dsl::name.asc())
|
||||||
.load(&mut conn)?,
|
.load(&mut conn)?,
|
||||||
None => folders_dsl::folders
|
None => folders_dsl::folders
|
||||||
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
.filter(folders_dsl::parent_id.is_null())
|
.filter(folders_dsl::parent_id.is_null())
|
||||||
.order(folders_dsl::name.asc())
|
.order(folders_dsl::name.asc())
|
||||||
.load(&mut conn)?,
|
.load(&mut conn)?,
|
||||||
@@ -241,6 +298,7 @@ fn fetch_folder_contents(
|
|||||||
|
|
||||||
let mut docs_query = documents_dsl::documents
|
let mut docs_query = documents_dsl::documents
|
||||||
.filter(documents_dsl::deleted_at.is_null())
|
.filter(documents_dsl::deleted_at.is_null())
|
||||||
|
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||||
.into_boxed();
|
.into_boxed();
|
||||||
|
|
||||||
docs_query = match folder_id {
|
docs_query = match folder_id {
|
||||||
@@ -290,8 +348,9 @@ async fn stream_document(
|
|||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let range_header = headers.get(header::RANGE).cloned();
|
let range_header = headers.get(header::RANGE).cloned();
|
||||||
|
|
||||||
let url = state
|
let storage = state.storage_for_tenant(document.tenant_id)?;
|
||||||
.storage
|
|
||||||
|
let url = storage
|
||||||
.presign_get_object(
|
.presign_get_object(
|
||||||
&version.s3_key,
|
&version.s3_key,
|
||||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||||
@@ -338,7 +397,7 @@ async fn stream_document(
|
|||||||
|
|
||||||
builder = builder.header("Accept-Ranges", "bytes");
|
builder = builder.header("Accept-Ranges", "bytes");
|
||||||
|
|
||||||
if let Some(disposition) = content_disposition(&document.filename) {
|
if let Some(disposition) = inline_content_disposition(&document.filename) {
|
||||||
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
|
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +419,7 @@ async fn stream_document(
|
|||||||
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
|
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> {
|
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
|
||||||
tracing::debug!("webdav authenticate invoked");
|
tracing::debug!("webdav authenticate invoked");
|
||||||
let authorization = match headers.get(header::AUTHORIZATION) {
|
let authorization = match headers.get(header::AUTHORIZATION) {
|
||||||
Some(value) => match value.to_str() {
|
Some(value) => match value.to_str() {
|
||||||
@@ -405,7 +464,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
|
|||||||
};
|
};
|
||||||
|
|
||||||
tracing::debug!(%username, "attempting webdav login");
|
tracing::debug!(%username, "attempting webdav login");
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let user: User = match users_dsl::users
|
let user: User = match users_dsl::users
|
||||||
.filter(users_dsl::username.eq(username))
|
.filter(users_dsl::username.eq(username))
|
||||||
@@ -427,10 +486,27 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(%username, "webdav login success");
|
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
|
||||||
Ok(Some(WebDavUser {
|
.inner_join(tenant_dsl::tenants)
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.select((tenant_dsl::id, tenant_dsl::slug))
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
if tenant_rows.is_empty() {
|
||||||
|
tracing::warn!(%username, "webdav user has no tenant memberships");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tenants: Vec<TenantEntry> = tenant_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
|
||||||
|
Ok(Some(WebDavContext {
|
||||||
_user_id: user.id,
|
_user_id: user.id,
|
||||||
_username: user.username,
|
_username: user.username,
|
||||||
|
tenants,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,10 +520,10 @@ fn build_resources_for_folder(
|
|||||||
|
|
||||||
let display_name = folder
|
let display_name = folder
|
||||||
.map(|folder| folder.name.clone())
|
.map(|folder| folder.name.clone())
|
||||||
.unwrap_or_else(|| "/".to_string());
|
.unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string()));
|
||||||
|
|
||||||
let href = build_href(chain, true);
|
let href = build_href(chain, true);
|
||||||
let last_modified = folder.map(|folder| format_http_date(folder.updated_at));
|
let last_modified = folder.map(|folder| to_http_date(folder.updated_at));
|
||||||
|
|
||||||
resources.push(DavResource {
|
resources.push(DavResource {
|
||||||
href,
|
href,
|
||||||
@@ -471,7 +547,7 @@ fn build_resources_for_folder(
|
|||||||
is_collection: true,
|
is_collection: true,
|
||||||
content_length: None,
|
content_length: None,
|
||||||
content_type: None,
|
content_type: None,
|
||||||
last_modified: Some(format_http_date(subfolder.updated_at)),
|
last_modified: Some(to_http_date(subfolder.updated_at)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +564,37 @@ fn build_resources_for_folder(
|
|||||||
resources
|
resources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
|
||||||
|
let mut resources = Vec::new();
|
||||||
|
|
||||||
|
resources.push(DavResource {
|
||||||
|
href: "/".to_string(),
|
||||||
|
display_name: "/".to_string(),
|
||||||
|
is_collection: true,
|
||||||
|
content_length: None,
|
||||||
|
content_type: None,
|
||||||
|
last_modified: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
if depth == 0 {
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
for tenant in tenants {
|
||||||
|
let href = build_href(&[tenant.slug.clone()], true);
|
||||||
|
resources.push(DavResource {
|
||||||
|
href,
|
||||||
|
display_name: tenant.slug.clone(),
|
||||||
|
is_collection: true,
|
||||||
|
content_length: None,
|
||||||
|
content_type: None,
|
||||||
|
last_modified: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resources
|
||||||
|
}
|
||||||
|
|
||||||
fn build_resources_for_document(
|
fn build_resources_for_document(
|
||||||
chain: &[String],
|
chain: &[String],
|
||||||
document: &Document,
|
document: &Document,
|
||||||
@@ -509,7 +616,7 @@ fn document_to_resource(
|
|||||||
is_collection: false,
|
is_collection: false,
|
||||||
content_length: Some(version.size_bytes),
|
content_length: Some(version.size_bytes),
|
||||||
content_type: document.content_type.clone(),
|
content_type: document.content_type.clone(),
|
||||||
last_modified: Some(format_http_date(document.updated_at)),
|
last_modified: Some(to_http_date(document.updated_at)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,32 +697,6 @@ fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::E
|
|||||||
Ok(writer.into_inner())
|
Ok(writer.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_http_date(value: chrono::NaiveDateTime) -> String {
|
|
||||||
let datetime = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(value, chrono::Utc);
|
|
||||||
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn content_disposition(filename: &str) -> Option<String> {
|
|
||||||
if filename.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitized: String = filename
|
|
||||||
.chars()
|
|
||||||
.map(|ch| match ch {
|
|
||||||
'"' | '\\' => '_',
|
|
||||||
_ => ch,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let encoded =
|
|
||||||
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
|
|
||||||
Some(format!(
|
|
||||||
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
|
||||||
sanitized, encoded
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WebDavFolderContents {
|
struct WebDavFolderContents {
|
||||||
_folder: Option<Folder>,
|
_folder: Option<Folder>,
|
||||||
subfolders: Vec<Folder>,
|
subfolders: Vec<Folder>,
|
||||||
@@ -635,9 +716,10 @@ struct DavResource {
|
|||||||
content_type: Option<String>,
|
content_type: Option<String>,
|
||||||
last_modified: Option<String>,
|
last_modified: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResolvedPath {
|
enum ResolvedPath {
|
||||||
Root,
|
TenantRoot {
|
||||||
|
chain: Vec<String>,
|
||||||
|
},
|
||||||
Folder {
|
Folder {
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
chain: Vec<String>,
|
chain: Vec<String>,
|
||||||
@@ -649,37 +731,37 @@ enum ResolvedPath {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
|
fn resolve_path(
|
||||||
if segments.is_empty() {
|
state: &AppState,
|
||||||
return Ok(Some(ResolvedPath::Root));
|
tenant: &TenantEntry,
|
||||||
}
|
segments: &[String],
|
||||||
|
) -> AppResult<Option<ResolvedPath>> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
|
||||||
let mut parent_id: Option<Uuid> = None;
|
let mut parent_id: Option<Uuid> = None;
|
||||||
let mut chain: Vec<String> = Vec::new();
|
let mut chain: Vec<String> = vec![tenant.slug.clone()];
|
||||||
let mut current_folder: Option<Folder> = None;
|
let mut current_folder: Option<Folder> = None;
|
||||||
|
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Ok(Some(ResolvedPath::TenantRoot { chain }));
|
||||||
|
}
|
||||||
|
|
||||||
for (index, segment) in segments.iter().enumerate() {
|
for (index, segment) in segments.iter().enumerate() {
|
||||||
let is_last = index == segments.len() - 1;
|
let is_last = index == segments.len() - 1;
|
||||||
|
|
||||||
match find_folder_by_name(&mut conn, parent_id, segment)? {
|
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
|
||||||
Some(folder) => {
|
{
|
||||||
if is_last {
|
chain.push(folder.name.clone());
|
||||||
chain.push(folder.name.clone());
|
if is_last {
|
||||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||||
}
|
|
||||||
|
|
||||||
parent_id = Some(folder.id);
|
|
||||||
chain.push(folder.name.clone());
|
|
||||||
current_folder = Some(folder);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
None => {}
|
parent_id = Some(folder.id);
|
||||||
|
current_folder = Some(folder);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_last {
|
if is_last {
|
||||||
if let Some((document, version)) =
|
if let Some((document, version)) =
|
||||||
find_document_by_filename(&mut conn, parent_id, segment)?
|
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
|
||||||
{
|
{
|
||||||
chain.push(document.filename.clone());
|
chain.push(document.filename.clone());
|
||||||
return Ok(Some(ResolvedPath::Document {
|
return Ok(Some(ResolvedPath::Document {
|
||||||
@@ -691,26 +773,22 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(uuid) = Uuid::parse_str(segment) {
|
if let Ok(uuid) = Uuid::parse_str(segment) {
|
||||||
if let Some(folder) = folders_dsl::folders
|
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
|
||||||
.find(uuid)
|
|
||||||
.first::<Folder>(&mut conn)
|
|
||||||
.optional()?
|
|
||||||
{
|
|
||||||
if folder.parent_id != parent_id {
|
if folder.parent_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if !is_last {
|
chain.push(folder.name.clone());
|
||||||
parent_id = Some(folder.id);
|
if is_last {
|
||||||
chain.push(folder.name.clone());
|
|
||||||
current_folder = Some(folder);
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
chain.push(folder.name.clone());
|
|
||||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||||
}
|
}
|
||||||
|
parent_id = Some(folder.id);
|
||||||
|
current_folder = Some(folder);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? {
|
if let Some((document, version)) =
|
||||||
|
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
|
||||||
|
{
|
||||||
if document.folder_id != parent_id {
|
if document.folder_id != parent_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -723,19 +801,6 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_last {
|
|
||||||
if let Some((document, version)) =
|
|
||||||
find_document_by_filename(&mut conn, parent_id, segment)?
|
|
||||||
{
|
|
||||||
chain.push(document.filename.clone());
|
|
||||||
return Ok(Some(ResolvedPath::Document {
|
|
||||||
document,
|
|
||||||
version,
|
|
||||||
chain,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,32 +809,46 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
|||||||
|
|
||||||
fn find_folder_by_name(
|
fn find_folder_by_name(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
parent_id: Option<Uuid>,
|
parent_id: Option<Uuid>,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> AppResult<Option<Folder>> {
|
) -> AppResult<Option<Folder>> {
|
||||||
let result = match parent_id {
|
let mut query = folders_dsl::folders
|
||||||
Some(parent) => folders_dsl::folders
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
.filter(folders_dsl::parent_id.eq(Some(parent)))
|
.into_boxed();
|
||||||
.filter(folders_dsl::name.eq(name))
|
|
||||||
.first::<Folder>(conn)
|
query = match parent_id {
|
||||||
.optional()?,
|
Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
|
||||||
None => folders_dsl::folders
|
None => query.filter(folders_dsl::parent_id.is_null()),
|
||||||
.filter(folders_dsl::parent_id.is_null())
|
|
||||||
.filter(folders_dsl::name.eq(name))
|
|
||||||
.first::<Folder>(conn)
|
|
||||||
.optional()?,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(result)
|
Ok(query
|
||||||
|
.filter(folders_dsl::name.eq(name))
|
||||||
|
.first::<Folder>(conn)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_folder_by_id(
|
||||||
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
folder_id: Uuid,
|
||||||
|
) -> AppResult<Option<Folder>> {
|
||||||
|
Ok(folders_dsl::folders
|
||||||
|
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||||
|
.find(folder_id)
|
||||||
|
.first::<Folder>(conn)
|
||||||
|
.optional()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_document_by_filename(
|
fn find_document_by_filename(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
parent_id: Option<Uuid>,
|
parent_id: Option<Uuid>,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||||
let mut query = documents_dsl::documents
|
let mut query = documents_dsl::documents
|
||||||
.filter(documents_dsl::deleted_at.is_null())
|
.filter(documents_dsl::deleted_at.is_null())
|
||||||
|
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||||
.filter(documents_dsl::filename.eq(filename))
|
.filter(documents_dsl::filename.eq(filename))
|
||||||
.into_boxed();
|
.into_boxed();
|
||||||
|
|
||||||
@@ -790,10 +869,12 @@ fn find_document_by_filename(
|
|||||||
|
|
||||||
fn find_document_by_id(
|
fn find_document_by_id(
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
document_id: Uuid,
|
document_id: Uuid,
|
||||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||||
if let Some(document) = documents_dsl::documents
|
if let Some(document) = documents_dsl::documents
|
||||||
.filter(documents_dsl::deleted_at.is_null())
|
.filter(documents_dsl::deleted_at.is_null())
|
||||||
|
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||||
.find(document_id)
|
.find(document_id)
|
||||||
.first::<Document>(conn)
|
.first::<Document>(conn)
|
||||||
.optional()?
|
.optional()?
|
||||||
|
|||||||
+50
-2
@@ -8,6 +8,7 @@ diesel::table! {
|
|||||||
metadata -> Jsonb,
|
metadata -> Jsonb,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ diesel::table! {
|
|||||||
ordinal -> Int4,
|
ordinal -> Int4,
|
||||||
s3_key -> Text,
|
s3_key -> Text,
|
||||||
metadata -> Jsonb,
|
metadata -> Jsonb,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ diesel::table! {
|
|||||||
metadata -> Jsonb,
|
metadata -> Jsonb,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
cardinality -> Nullable<Int4>,
|
cardinality -> Nullable<Int4>,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ diesel::table! {
|
|||||||
role -> Varchar,
|
role -> Varchar,
|
||||||
assigned_at -> Timestamptz,
|
assigned_at -> Timestamptz,
|
||||||
assigned_by -> Nullable<Uuid>,
|
assigned_by -> Nullable<Uuid>,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +54,7 @@ diesel::table! {
|
|||||||
tag_id -> Uuid,
|
tag_id -> Uuid,
|
||||||
assigned_at -> Timestamptz,
|
assigned_at -> Timestamptz,
|
||||||
assigned_by -> Nullable<Uuid>,
|
assigned_by -> Nullable<Uuid>,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +71,7 @@ diesel::table! {
|
|||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
operations_summary -> Jsonb,
|
operations_summary -> Jsonb,
|
||||||
metadata -> Jsonb,
|
metadata -> Jsonb,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +93,7 @@ diesel::table! {
|
|||||||
#[max_length = 255]
|
#[max_length = 255]
|
||||||
title -> Varchar,
|
title -> Varchar,
|
||||||
current_version_id -> Uuid,
|
current_version_id -> Uuid,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +105,7 @@ diesel::table! {
|
|||||||
parent_id -> Nullable<Uuid>,
|
parent_id -> Nullable<Uuid>,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +120,7 @@ diesel::table! {
|
|||||||
last_error -> Nullable<Text>,
|
last_error -> Nullable<Text>,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +134,7 @@ diesel::table! {
|
|||||||
revoked_at -> Nullable<Timestamptz>,
|
revoked_at -> Nullable<Timestamptz>,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
|
tenant_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +146,31 @@ diesel::table! {
|
|||||||
#[max_length = 7]
|
#[max_length = 7]
|
||||||
color -> Nullable<Varchar>,
|
color -> Nullable<Varchar>,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
|
tenant_id -> Uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
tenants (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
slug -> Text,
|
||||||
|
storage_root -> Nullable<Text>,
|
||||||
|
quickwit_index -> Nullable<Text>,
|
||||||
|
status -> Text,
|
||||||
|
config -> Jsonb,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
updated_at -> Timestamptz,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
user_memberships (id) {
|
||||||
|
id -> Uuid,
|
||||||
|
user_id -> Uuid,
|
||||||
|
tenant_id -> Uuid,
|
||||||
|
role -> Text,
|
||||||
|
created_at -> Timestamptz,
|
||||||
|
updated_at -> Timestamptz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,23 +181,34 @@ diesel::table! {
|
|||||||
username -> Varchar,
|
username -> Varchar,
|
||||||
#[max_length = 255]
|
#[max_length = 255]
|
||||||
password_hash -> Varchar,
|
password_hash -> Varchar,
|
||||||
#[max_length = 16]
|
|
||||||
role -> Varchar,
|
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::joinable!(correspondents -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
|
||||||
|
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_assets -> document_versions (document_version_id));
|
diesel::joinable!(document_assets -> document_versions (document_version_id));
|
||||||
|
diesel::joinable!(document_assets -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
|
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
|
||||||
diesel::joinable!(document_correspondents -> documents (document_id));
|
diesel::joinable!(document_correspondents -> documents (document_id));
|
||||||
|
diesel::joinable!(document_correspondents -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_correspondents -> users (assigned_by));
|
diesel::joinable!(document_correspondents -> users (assigned_by));
|
||||||
diesel::joinable!(document_tags -> documents (document_id));
|
diesel::joinable!(document_tags -> documents (document_id));
|
||||||
diesel::joinable!(document_tags -> tags (tag_id));
|
diesel::joinable!(document_tags -> tags (tag_id));
|
||||||
|
diesel::joinable!(document_tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(document_tags -> users (assigned_by));
|
diesel::joinable!(document_tags -> users (assigned_by));
|
||||||
|
diesel::joinable!(document_versions -> tenants (tenant_id));
|
||||||
diesel::joinable!(documents -> folders (folder_id));
|
diesel::joinable!(documents -> folders (folder_id));
|
||||||
|
diesel::joinable!(documents -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(folders -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(refresh_tokens -> tenants (tenant_id));
|
||||||
diesel::joinable!(refresh_tokens -> users (user_id));
|
diesel::joinable!(refresh_tokens -> users (user_id));
|
||||||
|
diesel::joinable!(tags -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||||
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
correspondents,
|
correspondents,
|
||||||
@@ -176,5 +222,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
jobs,
|
jobs,
|
||||||
refresh_tokens,
|
refresh_tokens,
|
||||||
tags,
|
tags,
|
||||||
|
tenants,
|
||||||
|
user_memberships,
|
||||||
users,
|
users,
|
||||||
);
|
);
|
||||||
|
|||||||
+41
-5
@@ -4,43 +4,79 @@ use diesel::{
|
|||||||
pg::PgConnection,
|
pg::PgConnection,
|
||||||
r2d2::{ConnectionManager, PooledConnection},
|
r2d2::{ConnectionManager, PooledConnection},
|
||||||
};
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::jwt::JwtService,
|
auth::jwt::JwtService,
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::PgPool,
|
db::PgPool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
storage::ObjectStorage,
|
storage::{ObjectStorage, TenantStorage},
|
||||||
|
tenants::{apply_tenant_guc, TenantService},
|
||||||
};
|
};
|
||||||
|
|
||||||
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
pub config: Arc<AppConfig>,
|
pub config: Arc<AppConfig>,
|
||||||
pub storage: Arc<dyn ObjectStorage>,
|
storage: Arc<dyn ObjectStorage>,
|
||||||
pub jwt: JwtService,
|
pub jwt: JwtService,
|
||||||
|
pub tenants: TenantService,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
pub async fn initialize(
|
||||||
|
config: AppConfig,
|
||||||
|
pool_size_override: Option<u32>,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size);
|
||||||
|
let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?;
|
||||||
|
let s3_client = crate::s3::build_client(&config).await?;
|
||||||
|
let storage = Arc::new(crate::storage::S3Storage::new(
|
||||||
|
s3_client,
|
||||||
|
config.s3_bucket.clone(),
|
||||||
|
));
|
||||||
|
let jwt = crate::auth::jwt::JwtService::from_config(&config)?;
|
||||||
|
|
||||||
|
Ok(Self::new(pool, config, storage, jwt))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
storage: Arc<dyn ObjectStorage>,
|
storage: Arc<dyn ObjectStorage>,
|
||||||
jwt: JwtService,
|
jwt: JwtService,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let config = Arc::new(config);
|
||||||
|
let tenants = TenantService::new(pool.clone());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
config: Arc::new(config),
|
config,
|
||||||
storage,
|
storage,
|
||||||
jwt,
|
jwt,
|
||||||
|
tenants,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn db(&self) -> AppResult<PgPooledConnection> {
|
pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult<PgPooledConnection> {
|
||||||
|
debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant");
|
||||||
|
let mut conn = self.db_unscoped()?;
|
||||||
|
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn db_unscoped(&self) -> AppResult<PgPooledConnection> {
|
||||||
self.pool
|
self.pool
|
||||||
.get()
|
.get()
|
||||||
.map_err(|err| AppError::internal(format!("database pool error: {err}")))
|
.map_err(|err| AppError::internal(format!("database pool error: {err}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult<TenantStorage> {
|
||||||
|
let tenant = self.tenants.get_by_id(tenant_id)?;
|
||||||
|
TenantStorage::new(self.storage.clone(), &tenant)
|
||||||
|
.map_err(|err| AppError::internal(format!("tenant storage error: {err}")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-1
@@ -1,11 +1,15 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use aws_sdk_s3::presigning::PresigningConfig;
|
use aws_sdk_s3::presigning::PresigningConfig;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::Client as S3Client;
|
use aws_sdk_s3::Client as S3Client;
|
||||||
|
|
||||||
|
use crate::models::Tenant;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ObjectStorage: Send + Sync + 'static {
|
pub trait ObjectStorage: Send + Sync + 'static {
|
||||||
async fn put_object(
|
async fn put_object(
|
||||||
@@ -119,3 +123,52 @@ impl ObjectStorage for S3Storage {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TenantStorage {
|
||||||
|
inner: Arc<dyn ObjectStorage>,
|
||||||
|
root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantStorage {
|
||||||
|
pub fn new(inner: Arc<dyn ObjectStorage>, tenant: &Tenant) -> Result<Self> {
|
||||||
|
let root = tenant
|
||||||
|
.storage_root
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("tenant {} missing storage_root", tenant.id))?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
Ok(Self { inner, root })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qualify(&self, key: &str) -> String {
|
||||||
|
format!("{}{}", self.root, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
content_type: Option<String>,
|
||||||
|
content_disposition: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let qualified = self.qualify(key);
|
||||||
|
self.inner
|
||||||
|
.put_object(&qualified, bytes, content_type, content_disposition)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
|
||||||
|
let qualified = self.qualify(key);
|
||||||
|
self.inner.presign_get_object(&qualified, expires_in).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
|
||||||
|
let qualified = self.qualify(key);
|
||||||
|
self.inner.get_object(&qualified).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_object(&self, key: &str) -> Result<()> {
|
||||||
|
let qualified = self.qualify(key);
|
||||||
|
self.inner.delete_object(&qualified).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
|
use diesel::{pg::PgConnection, prelude::*, sql_types::Text};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::PgPool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::Tenant,
|
||||||
|
schema::tenants::dsl,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct TenantRepository;
|
||||||
|
|
||||||
|
impl TenantRepository {
|
||||||
|
pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<Tenant> {
|
||||||
|
dsl::tenants.find(tenant_id).first(conn).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_slug(conn: &mut PgConnection, slug: &str) -> AppResult<Tenant> {
|
||||||
|
dsl::tenants
|
||||||
|
.filter(dsl::slug.eq(slug))
|
||||||
|
.first(conn)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TenantService {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantService {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult<Tenant> {
|
||||||
|
let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?;
|
||||||
|
Ok(tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_slug(&self, slug: &str) -> AppResult<Tenant> {
|
||||||
|
let slug_owned = slug.to_owned();
|
||||||
|
let tenant = self.load(|conn| TenantRepository::get_by_slug(conn, &slug_owned))?;
|
||||||
|
Ok(tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tenant_id_for_slug(&self, slug: &str) -> AppResult<Uuid> {
|
||||||
|
Ok(self.get_by_slug(slug)?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<F>(&self, loader: F) -> AppResult<Tenant>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
|
||||||
|
{
|
||||||
|
let mut conn = self
|
||||||
|
.pool
|
||||||
|
.get()
|
||||||
|
.map_err(|err| AppError::internal(format!("database pool error: {err}")))?;
|
||||||
|
let tenant = loader(&mut conn)?;
|
||||||
|
Ok(tenant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> {
|
||||||
|
diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, true)")
|
||||||
|
.bind::<Text, _>(tenant_id.to_string())
|
||||||
|
.execute(conn)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(AppError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TenantContext {
|
||||||
|
pub tenant: Tenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FromRequestParts<AppState> for TenantContext {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
_parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let tenant = state
|
||||||
|
.tenants
|
||||||
|
.get_by_slug(&state.config.default_tenant_slug)?;
|
||||||
|
Ok(Self { tenant })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing};
|
||||||
|
|
||||||
|
/// Initialize tracing, load configuration, and build the shared `AppState`.
|
||||||
|
/// Optionally override the connection pool size for lightweight components.
|
||||||
|
pub async fn init_component(name: &str, pool_override: Option<u32>) -> Result<Arc<AppState>> {
|
||||||
|
init_tracing("info");
|
||||||
|
let config = AppConfig::load_and_log(name)?;
|
||||||
|
let state = AppState::initialize(config, pool_override).await?;
|
||||||
|
Ok(Arc::new(state))
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use diesel::{pg::PgConnection, result::Error as DieselError};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait EnsureEntity<T> {
|
||||||
|
fn one(self) -> AppResult<T>;
|
||||||
|
fn maybe(self) -> AppResult<Option<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> EnsureEntity<T> for Result<T, DieselError> {
|
||||||
|
fn one(self) -> AppResult<T> {
|
||||||
|
self.map_err(AppError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe(self) -> AppResult<Option<T>> {
|
||||||
|
match self {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(DieselError::NotFound) => Ok(None),
|
||||||
|
Err(err) => Err(AppError::from(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn with_tenant_conn<F, T>(&self, tenant_id: Uuid, f: F) -> AppResult<T>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut PgConnection) -> AppResult<T>,
|
||||||
|
{
|
||||||
|
let mut conn = self.db_for_tenant(tenant_id)?;
|
||||||
|
f(&mut conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_bulk_ids(ids: &mut Vec<Uuid>, label: &str) -> AppResult<()> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Err(AppError::bad_request(format!("{label} must not be empty")));
|
||||||
|
}
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IntoJsonResponse<T> {
|
||||||
|
fn into_json(self) -> AppResult<axum::Json<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> IntoJsonResponse<T> for T {
|
||||||
|
fn into_json(self) -> AppResult<axum::Json<T>> {
|
||||||
|
Ok(axum::Json(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn no_content() -> AppResult<axum::http::StatusCode> {
|
||||||
|
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||||
|
|
||||||
|
/// Build an inline `Content-Disposition` header value for a given filename.
|
||||||
|
pub fn inline_content_disposition(filename: &str) -> Option<String> {
|
||||||
|
if filename.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized: String = filename
|
||||||
|
.chars()
|
||||||
|
.map(|ch| match ch {
|
||||||
|
'"' | '\\' => '_',
|
||||||
|
_ => ch,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let encoded = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC);
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
||||||
|
sanitized, encoded
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1 +1,8 @@
|
|||||||
|
pub mod bootstrap;
|
||||||
|
pub mod db;
|
||||||
|
pub mod http;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
|
pub mod storage_paths;
|
||||||
|
pub mod time;
|
||||||
|
pub mod tracing;
|
||||||
|
pub mod validation;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Document storage path helpers.
|
||||||
|
//!
|
||||||
|
//! NOTE: The path layout produced here is part of the durable storage contract.
|
||||||
|
//! External systems (presigned URLs, lifecycle jobs, migrations) expect the
|
||||||
|
//! `documents/{document_id}/...` structure to remain stable. Coordinate before
|
||||||
|
//! changing any of these helpers to avoid breaking compatibility with existing
|
||||||
|
//! objects.
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DOCUMENTS_PREFIX: &str = "documents";
|
||||||
|
|
||||||
|
/// Returns the root prefix for all objects belonging to a document.
|
||||||
|
pub fn document_prefix(document_id: Uuid) -> String {
|
||||||
|
format!("{DOCUMENTS_PREFIX}/{document_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the prefix for a specific document version (without the object id).
|
||||||
|
pub fn document_version_prefix(document_id: Uuid, version_number: i32) -> String {
|
||||||
|
format!("{}/v{}", document_prefix(document_id), version_number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the storage key for a stored document version blob.
|
||||||
|
pub fn document_version_object_key(
|
||||||
|
document_id: Uuid,
|
||||||
|
version_number: i32,
|
||||||
|
version_id: Uuid,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
document_version_prefix(document_id, version_number),
|
||||||
|
version_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_asset_prefix(document_id: Uuid, version_number: i32) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/assets",
|
||||||
|
document_version_prefix(document_id, version_number)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_asset_type_prefix(document_id: Uuid, version_number: i32, asset_type: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
document_asset_prefix(document_id, version_number),
|
||||||
|
asset_type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the storage prefix under which the asset objects for a type/id pair live.
|
||||||
|
pub fn document_asset_object_prefix(
|
||||||
|
document_id: Uuid,
|
||||||
|
version_number: i32,
|
||||||
|
asset_type: &str,
|
||||||
|
asset_id: Uuid,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
document_asset_type_prefix(document_id, version_number, asset_type),
|
||||||
|
asset_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the full storage key for a specific asset object (ordinal).
|
||||||
|
pub fn document_asset_object_key(
|
||||||
|
document_id: Uuid,
|
||||||
|
version_number: i32,
|
||||||
|
asset_type: &str,
|
||||||
|
asset_id: Uuid,
|
||||||
|
ordinal: i32,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
document_asset_object_prefix(document_id, version_number, asset_type, asset_id),
|
||||||
|
ordinal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_expected_paths() {
|
||||||
|
let document_id = Uuid::nil();
|
||||||
|
let version_id = Uuid::nil();
|
||||||
|
let asset_id = Uuid::nil();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
document_prefix(document_id),
|
||||||
|
format!("documents/{document_id}")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
document_version_prefix(document_id, 3),
|
||||||
|
format!("documents/{document_id}/v3")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
document_version_object_key(document_id, 3, version_id),
|
||||||
|
format!("documents/{document_id}/v3/{version_id}")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
document_asset_object_prefix(document_id, 3, "preview", asset_id),
|
||||||
|
format!("documents/{document_id}/v3/assets/preview/{asset_id}")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
document_asset_object_key(document_id, 3, "preview", asset_id, 2),
|
||||||
|
format!("documents/{document_id}/v3/assets/preview/{asset_id}/2")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
|
||||||
|
/// Format a timestamp as RFC3339 using UTC.
|
||||||
|
pub fn to_iso(dt: NaiveDateTime) -> String {
|
||||||
|
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a timestamp for HTTP headers (RFC 7231 date).
|
||||||
|
pub fn to_http_date(dt: NaiveDateTime) -> String {
|
||||||
|
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)
|
||||||
|
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
/// Initialize tracing with an optional default level.
|
||||||
|
///
|
||||||
|
/// Falls back to `default_level` when `RUST_LOG` is not provided.
|
||||||
|
pub fn init_tracing(default_level: &str) {
|
||||||
|
let filter =
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.compact()
|
||||||
|
.init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
/// Ensure an entity exists, returning a bad request error when it does not.
|
||||||
|
pub fn ensure_exists(exists: bool, entity: &str) -> AppResult<()> {
|
||||||
|
if exists {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::bad_request(format!("{entity} does not exist")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use crate::{
|
|||||||
models::{Document, DocumentAsset, DocumentVersion},
|
models::{Document, DocumentAsset, DocumentVersion},
|
||||||
schema::{document_assets, document_versions, documents},
|
schema::{document_assets, document_versions, documents},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
storage::TenantStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{JobExecution, JobHandler};
|
use super::{JobExecution, JobHandler};
|
||||||
@@ -40,7 +41,12 @@ impl JobHandler for AnalyzeDocumentJob {
|
|||||||
JOB_ANALYZE_DOCUMENT
|
JOB_ANALYZE_DOCUMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
job: crate::models::Job,
|
||||||
|
_storage: TenantStorage,
|
||||||
|
) -> JobExecution {
|
||||||
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
|
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
|
||||||
Ok(payload) => payload,
|
Ok(payload) => payload,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -51,7 +57,9 @@ impl JobHandler for AnalyzeDocumentJob {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
match task::spawn_blocking(move || analyze_document(state_clone, payload)).await {
|
let tenant_id = job.tenant_id;
|
||||||
|
match task::spawn_blocking(move || analyze_document(state_clone, tenant_id, payload)).await
|
||||||
|
{
|
||||||
Ok(Ok(execution)) => execution,
|
Ok(Ok(execution)) => execution,
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
warn!(job_id = %job.id, error = %err, "analyze job will retry");
|
warn!(job_id = %job.id, error = %err, "analyze job will retry");
|
||||||
@@ -71,8 +79,14 @@ impl JobHandler for AnalyzeDocumentJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> {
|
fn analyze_document(
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
state: Arc<AppState>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
payload: AnalyzePayload,
|
||||||
|
) -> Result<JobExecution, String> {
|
||||||
|
let mut conn = state
|
||||||
|
.db_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
let version: DocumentVersion = document_versions::table
|
||||||
.find(payload.document_version_id)
|
.find(payload.document_version_id)
|
||||||
@@ -88,12 +102,15 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
|
|||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let tenant_id = document.tenant_id;
|
||||||
|
|
||||||
let (supported, reason) = determine_thumbnail_support(&document);
|
let (supported, reason) = determine_thumbnail_support(&document);
|
||||||
let ocr_supported = document_is_pdf(&document);
|
let ocr_supported = document_is_pdf(&document);
|
||||||
|
|
||||||
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
||||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||||
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
@@ -129,6 +146,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
|
|||||||
if supported {
|
if supported {
|
||||||
let enqueue_result = enqueue_job(
|
let enqueue_result = enqueue_job(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
|
tenant_id,
|
||||||
JOB_GENERATE_THUMBNAILS,
|
JOB_GENERATE_THUMBNAILS,
|
||||||
json!({
|
json!({
|
||||||
"document_id": payload.document_id,
|
"document_id": payload.document_id,
|
||||||
@@ -146,6 +164,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
|
|||||||
if ocr_supported && !skip_ocr {
|
if ocr_supported && !skip_ocr {
|
||||||
let enqueue_result = enqueue_job(
|
let enqueue_result = enqueue_job(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
|
tenant_id,
|
||||||
JOB_GENERATE_OCR_TEXT,
|
JOB_GENERATE_OCR_TEXT,
|
||||||
json!({
|
json!({
|
||||||
"document_id": payload.document_id,
|
"document_id": payload.document_id,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::{
|
|||||||
models::{Document, DocumentVersion},
|
models::{Document, DocumentVersion},
|
||||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
storage::TenantStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
|
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
|
||||||
@@ -39,7 +40,12 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
JOB_INDEX_DOCUMENT_TEXT
|
JOB_INDEX_DOCUMENT_TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
job: crate::models::Job,
|
||||||
|
storage: TenantStorage,
|
||||||
|
) -> JobExecution {
|
||||||
let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) {
|
let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) {
|
||||||
Ok(payload) => payload,
|
Ok(payload) => payload,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -52,16 +58,29 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
let quickwit_endpoint = match &state.config.quickwit_endpoint {
|
let quickwit_endpoint = match &state.config.quickwit_endpoint {
|
||||||
Some(endpoint) => endpoint.clone(),
|
Some(endpoint) => endpoint.clone(),
|
||||||
None => {
|
None => {
|
||||||
warn!("quickwit endpoint missing; skipping indexing");
|
return JobExecution::Failed {
|
||||||
return JobExecution::Success;
|
error: "quickwit endpoint missing".into(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let quickwit_index = match &state.config.quickwit_index {
|
let tenant = match state.tenants.get_by_id(job.tenant_id) {
|
||||||
Some(index) => index.clone(),
|
Ok(tenant) => tenant,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(job_id = %job.id, error = ?err, "failed to load tenant for indexing");
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: Duration::from_secs(30),
|
||||||
|
error: format!("failed to load tenant: {err:?}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let quickwit_index = match tenant.quickwit_index.clone() {
|
||||||
|
Some(index) => index,
|
||||||
None => {
|
None => {
|
||||||
warn!("quickwit index missing; skipping indexing");
|
return JobExecution::Failed {
|
||||||
return JobExecution::Success;
|
error: "tenant quickwit index not configured".into(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +114,7 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let s3_key = context.text_s3_key.unwrap();
|
let s3_key = context.text_s3_key.unwrap();
|
||||||
let text = match state.storage.get_object(&s3_key).await {
|
let text = match storage.get_object(&s3_key).await {
|
||||||
Ok(bytes) => match String::from_utf8(bytes) {
|
Ok(bytes) => match String::from_utf8(bytes) {
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -129,6 +148,7 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
let payload = json!({
|
let payload = json!({
|
||||||
"document_id": context.document.id,
|
"document_id": context.document.id,
|
||||||
"version_id": context.version.id,
|
"version_id": context.version.id,
|
||||||
|
"tenant_id": job.tenant_id,
|
||||||
"title": context.document.title.to_lowercase(),
|
"title": context.document.title.to_lowercase(),
|
||||||
"text": text.to_lowercase()
|
"text": text.to_lowercase()
|
||||||
});
|
});
|
||||||
@@ -173,11 +193,11 @@ struct IndexContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
|
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
let version: DocumentVersion = document_versions::table
|
||||||
.find(payload.document_version_id)
|
.find(payload.document_version_id)
|
||||||
.first(&mut conn)
|
.first(&mut base_conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
if version.document_id != payload.document_id {
|
if version.document_id != payload.document_id {
|
||||||
@@ -186,7 +206,14 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
|
|||||||
|
|
||||||
let document: Document = documents::table
|
let document: Document = documents::table
|
||||||
.find(payload.document_id)
|
.find(payload.document_id)
|
||||||
.first(&mut conn)
|
.first(&mut base_conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let tenant_id = document.tenant_id;
|
||||||
|
drop(base_conn);
|
||||||
|
|
||||||
|
let mut conn = state
|
||||||
|
.db_for_tenant(tenant_id)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let text_s3_key: Option<String> = document_asset_objects::table
|
let text_s3_key: Option<String> = document_asset_objects::table
|
||||||
@@ -195,7 +222,9 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
|
|||||||
)
|
)
|
||||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||||
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||||
.filter(document_asset_objects::ordinal.eq(1))
|
.filter(document_asset_objects::ordinal.eq(1))
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||||
.select(document_asset_objects::s3_key)
|
.select(document_asset_objects::s3_key)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::{
|
|||||||
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
|
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
|
||||||
models::Job,
|
models::Job,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
storage::TenantStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod analyze;
|
pub mod analyze;
|
||||||
@@ -25,7 +26,7 @@ pub enum JobExecution {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait JobHandler: Send + Sync {
|
pub trait JobHandler: Send + Sync {
|
||||||
fn job_type(&self) -> &'static str;
|
fn job_type(&self) -> &'static str;
|
||||||
async fn handle(&self, state: Arc<AppState>, job: Job) -> JobExecution;
|
async fn handle(&self, state: Arc<AppState>, job: Job, storage: TenantStorage) -> JobExecution;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Worker {
|
pub struct Worker {
|
||||||
@@ -71,7 +72,7 @@ impl Worker {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = match self.state.db() {
|
let mut conn = match self.state.db_unscoped() {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!(?err, "failed to obtain database connection in worker");
|
error!(?err, "failed to obtain database connection in worker");
|
||||||
@@ -84,10 +85,22 @@ impl Worker {
|
|||||||
|
|
||||||
if let Some(job) = job_opt {
|
if let Some(job) = job_opt {
|
||||||
if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
|
if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
|
||||||
let result = handler.handle(self.state.clone(), job.clone()).await;
|
let execution = match self.state.storage_for_tenant(job.tenant_id) {
|
||||||
match result {
|
Ok(storage) => {
|
||||||
|
handler
|
||||||
|
.handle(self.state.clone(), job.clone(), storage)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!(job_id = %job.id, error = ?err, "failed to load tenant storage for job");
|
||||||
|
JobExecution::Failed {
|
||||||
|
error: format!("tenant storage unavailable: {err:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match execution {
|
||||||
JobExecution::Success => {
|
JobExecution::Success => {
|
||||||
if let Ok(mut conn) = self.state.db() {
|
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||||
mark_job_succeeded(&mut conn, job.id)?;
|
mark_job_succeeded(&mut conn, job.id)?;
|
||||||
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
|
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
|
||||||
} else {
|
} else {
|
||||||
@@ -96,7 +109,7 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
JobExecution::Retry { delay, error } => {
|
JobExecution::Retry { delay, error } => {
|
||||||
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
|
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
|
||||||
if let Ok(mut conn) = self.state.db() {
|
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||||
retry_job_after(&mut conn, job.id, delay, &error)?;
|
retry_job_after(&mut conn, job.id, delay, &error)?;
|
||||||
} else {
|
} else {
|
||||||
error!("failed to requeue job for retry due to pool error");
|
error!("failed to requeue job for retry due to pool error");
|
||||||
@@ -104,7 +117,7 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
JobExecution::Failed { error } => {
|
JobExecution::Failed { error } => {
|
||||||
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
|
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
|
||||||
if let Ok(mut conn) = self.state.db() {
|
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||||
mark_job_failed(&mut conn, job.id, &error)?;
|
mark_job_failed(&mut conn, job.id, &error)?;
|
||||||
} else {
|
} else {
|
||||||
error!("failed to mark job failed due to pool error");
|
error!("failed to mark job failed due to pool error");
|
||||||
@@ -113,7 +126,7 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!(job_type = %job.job_type, "no handler registered for job type");
|
error!(job_type = %job.job_type, "no handler registered for job type");
|
||||||
if let Ok(mut conn) = self.state.db() {
|
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||||
mark_job_failed(&mut conn, job.id, "no handler registered")?;
|
mark_job_failed(&mut conn, job.id, "no handler registered")?;
|
||||||
} else {
|
} else {
|
||||||
error!("failed to mark job failed for missing handler due to pool error");
|
error!("failed to mark job failed for missing handler due to pool error");
|
||||||
|
|||||||
+49
-18
@@ -25,6 +25,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
storage::TenantStorage,
|
||||||
|
utils::storage_paths::document_asset_object_prefix,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{JobExecution, JobHandler};
|
use super::{JobExecution, JobHandler};
|
||||||
@@ -54,7 +56,12 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
JOB_GENERATE_OCR_TEXT
|
JOB_GENERATE_OCR_TEXT
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
job: crate::models::Job,
|
||||||
|
storage: TenantStorage,
|
||||||
|
) -> JobExecution {
|
||||||
let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) {
|
let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) {
|
||||||
Ok(payload) => payload,
|
Ok(payload) => payload,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -91,7 +98,7 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
return JobExecution::Success;
|
return JobExecution::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = match state.storage.get_object(&context.version.s3_key).await {
|
let bytes = match storage.get_object(&context.version.s3_key).await {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
|
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
|
||||||
@@ -128,7 +135,7 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
|
|
||||||
if context.existing_asset.is_some() {
|
if context.existing_asset.is_some() {
|
||||||
for object in &context.existing_objects {
|
for object in &context.existing_objects {
|
||||||
if let Err(err) = state.storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,13 +143,14 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
|
|
||||||
let asset_id = Uuid::new_v4();
|
let asset_id = Uuid::new_v4();
|
||||||
|
|
||||||
let s3_key = format!(
|
let s3_key = document_asset_object_prefix(
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
context.document.id,
|
||||||
context.document.id, context.version.version_number, OCR_TEXT_ASSET_TYPE, asset_id
|
context.version.version_number,
|
||||||
|
OCR_TEXT_ASSET_TYPE,
|
||||||
|
asset_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = storage
|
||||||
.storage
|
|
||||||
.put_object(
|
.put_object(
|
||||||
&s3_key,
|
&s3_key,
|
||||||
generation.text.into_bytes(),
|
generation.text.into_bytes(),
|
||||||
@@ -165,11 +173,8 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
if state.config.quickwit_endpoint.is_some() && state.config.quickwit_index.is_some()
|
if let Err(err) = enqueue_index_job(&state, &payload) {
|
||||||
{
|
warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
|
||||||
if let Err(err) = enqueue_index_job(&state, &payload) {
|
|
||||||
warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
JobExecution::Success
|
JobExecution::Success
|
||||||
}
|
}
|
||||||
@@ -210,11 +215,11 @@ struct OcrGeneration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
|
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
let version: DocumentVersion = document_versions::table
|
||||||
.find(payload.document_version_id)
|
.find(payload.document_version_id)
|
||||||
.first(&mut conn)
|
.first(&mut base_conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
if version.document_id != payload.document_id {
|
if version.document_id != payload.document_id {
|
||||||
@@ -223,12 +228,20 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
|
|||||||
|
|
||||||
let document: Document = documents::table
|
let document: Document = documents::table
|
||||||
.find(payload.document_id)
|
.find(payload.document_id)
|
||||||
.first(&mut conn)
|
.first(&mut base_conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let tenant_id = document.tenant_id;
|
||||||
|
drop(base_conn);
|
||||||
|
|
||||||
|
let mut conn = state
|
||||||
|
.db_for_tenant(tenant_id)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let existing_asset: Option<DocumentAsset> = document_assets::table
|
let existing_asset: Option<DocumentAsset> = document_assets::table
|
||||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||||
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
@@ -236,6 +249,7 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
|
|||||||
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
||||||
document_asset_objects::table
|
document_asset_objects::table
|
||||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||||
.order(document_asset_objects::ordinal.asc())
|
.order(document_asset_objects::ordinal.asc())
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?
|
.map_err(|err| format!("{err:?}"))?
|
||||||
@@ -389,7 +403,10 @@ fn persist_ocr_metadata(
|
|||||||
s3_key: &str,
|
s3_key: &str,
|
||||||
source: &'static str,
|
source: &'static str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let tenant_id = context.document.tenant_id;
|
||||||
|
let mut conn = state
|
||||||
|
.db_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
if let Some(existing_asset) = &context.existing_asset {
|
if let Some(existing_asset) = &context.existing_asset {
|
||||||
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
|
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
|
||||||
@@ -407,6 +424,7 @@ fn persist_ocr_metadata(
|
|||||||
"source": source,
|
"source": source,
|
||||||
}),
|
}),
|
||||||
cardinality: Some(1),
|
cardinality: Some(1),
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_assets::table)
|
diesel::insert_into(document_assets::table)
|
||||||
@@ -427,6 +445,7 @@ fn persist_ocr_metadata(
|
|||||||
let existing_object_id: Option<Uuid> = document_asset_objects::table
|
let existing_object_id: Option<Uuid> = document_asset_objects::table
|
||||||
.filter(document_asset_objects::asset_id.eq(asset_id))
|
.filter(document_asset_objects::asset_id.eq(asset_id))
|
||||||
.filter(document_asset_objects::ordinal.eq(1))
|
.filter(document_asset_objects::ordinal.eq(1))
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||||
.select(document_asset_objects::id)
|
.select(document_asset_objects::id)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()
|
||||||
@@ -440,6 +459,7 @@ fn persist_ocr_metadata(
|
|||||||
ordinal: 1,
|
ordinal: 1,
|
||||||
s3_key: s3_key.to_string(),
|
s3_key: s3_key.to_string(),
|
||||||
metadata: json!({}),
|
metadata: json!({}),
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_asset_objects::table)
|
diesel::insert_into(document_asset_objects::table)
|
||||||
@@ -460,9 +480,20 @@ fn persist_ocr_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
|
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||||
|
let tenant_id: Uuid = documents::table
|
||||||
|
.find(payload.document_id)
|
||||||
|
.select(documents::tenant_id)
|
||||||
|
.first(&mut base_conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
drop(base_conn);
|
||||||
|
|
||||||
|
let mut conn = state
|
||||||
|
.db_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
enqueue_job(
|
enqueue_job(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
|
tenant_id,
|
||||||
JOB_INDEX_DOCUMENT_TEXT,
|
JOB_INDEX_DOCUMENT_TEXT,
|
||||||
json!({
|
json!({
|
||||||
"document_id": payload.document_id,
|
"document_id": payload.document_id,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
storage::TenantStorage,
|
||||||
|
utils::storage_paths::document_asset_object_key,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
||||||
@@ -52,7 +54,12 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
JOB_GENERATE_THUMBNAILS
|
JOB_GENERATE_THUMBNAILS
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
|
async fn handle(
|
||||||
|
&self,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
job: crate::models::Job,
|
||||||
|
storage: TenantStorage,
|
||||||
|
) -> JobExecution {
|
||||||
let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) {
|
let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -88,7 +95,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
return JobExecution::Success;
|
return JobExecution::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = match state.storage.get_object(&initial.version.s3_key).await {
|
let bytes = match storage.get_object(&initial.version.s3_key).await {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
|
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
|
||||||
@@ -147,7 +154,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
|
|
||||||
if initial.existing_preview.is_some() {
|
if initial.existing_preview.is_some() {
|
||||||
for object in &initial.existing_preview_objects {
|
for object in &initial.existing_preview_objects {
|
||||||
if let Err(err) = state.storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(
|
warn!(
|
||||||
job_id = %job.id,
|
job_id = %job.id,
|
||||||
error = %err,
|
error = %err,
|
||||||
@@ -160,7 +167,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
|
|
||||||
if initial.existing_thumbnail.is_some() {
|
if initial.existing_thumbnail.is_some() {
|
||||||
for object in &initial.existing_thumbnail_objects {
|
for object in &initial.existing_thumbnail_objects {
|
||||||
if let Err(err) = state.storage.delete_object(&object.s3_key).await {
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
warn!(
|
warn!(
|
||||||
job_id = %job.id,
|
job_id = %job.id,
|
||||||
error = %err,
|
error = %err,
|
||||||
@@ -172,22 +179,8 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let preview_asset_id = Uuid::new_v4();
|
let preview_asset_id = Uuid::new_v4();
|
||||||
let preview_base = format!(
|
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
|
||||||
initial.document.id,
|
|
||||||
initial.version.version_number,
|
|
||||||
PREVIEW_ASSET_TYPE,
|
|
||||||
preview_asset_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let thumbnail_asset_id = Uuid::new_v4();
|
let thumbnail_asset_id = Uuid::new_v4();
|
||||||
let thumbnail_base = format!(
|
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
|
||||||
initial.document.id,
|
|
||||||
initial.version.version_number,
|
|
||||||
THUMBNAIL_ASSET_TYPE,
|
|
||||||
thumbnail_asset_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut preview_objects: Vec<AssetObjectPersistence> =
|
let mut preview_objects: Vec<AssetObjectPersistence> =
|
||||||
Vec::with_capacity(generation.preview.objects.len());
|
Vec::with_capacity(generation.preview.objects.len());
|
||||||
@@ -198,10 +191,15 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let ordinal = (index + 1) as i32;
|
let ordinal = (index + 1) as i32;
|
||||||
let s3_key = format!("{preview_base}/{ordinal}");
|
let s3_key = document_asset_object_key(
|
||||||
|
initial.document.id,
|
||||||
|
initial.version.version_number,
|
||||||
|
PREVIEW_ASSET_TYPE,
|
||||||
|
preview_asset_id,
|
||||||
|
ordinal,
|
||||||
|
);
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = storage
|
||||||
.storage
|
|
||||||
.put_object(
|
.put_object(
|
||||||
&s3_key,
|
&s3_key,
|
||||||
image.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
@@ -234,10 +232,15 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let ordinal = (index + 1) as i32;
|
let ordinal = (index + 1) as i32;
|
||||||
let s3_key = format!("{thumbnail_base}/{ordinal}");
|
let s3_key = document_asset_object_key(
|
||||||
|
initial.document.id,
|
||||||
|
initial.version.version_number,
|
||||||
|
THUMBNAIL_ASSET_TYPE,
|
||||||
|
thumbnail_asset_id,
|
||||||
|
ordinal,
|
||||||
|
);
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = storage
|
||||||
.storage
|
|
||||||
.put_object(
|
.put_object(
|
||||||
&s3_key,
|
&s3_key,
|
||||||
image.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
@@ -344,7 +347,7 @@ fn load_thumbnail_context(
|
|||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
payload: &ThumbnailPayload,
|
payload: &ThumbnailPayload,
|
||||||
) -> Result<ThumbnailContext, String> {
|
) -> Result<ThumbnailContext, String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
let version: DocumentVersion = document_versions::table
|
||||||
.find(payload.document_version_id)
|
.find(payload.document_version_id)
|
||||||
@@ -360,12 +363,15 @@ fn load_thumbnail_context(
|
|||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let tenant_id = document.tenant_id;
|
||||||
|
|
||||||
let existing_assets: Vec<DocumentAsset> = document_assets::table
|
let existing_assets: Vec<DocumentAsset> = document_assets::table
|
||||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||||
.filter(document_assets::asset_type.eq_any(vec![
|
.filter(document_assets::asset_type.eq_any(vec![
|
||||||
THUMBNAIL_ASSET_TYPE.to_string(),
|
THUMBNAIL_ASSET_TYPE.to_string(),
|
||||||
PREVIEW_ASSET_TYPE.to_string(),
|
PREVIEW_ASSET_TYPE.to_string(),
|
||||||
]))
|
]))
|
||||||
|
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
@@ -378,6 +384,7 @@ fn load_thumbnail_context(
|
|||||||
THUMBNAIL_ASSET_TYPE => {
|
THUMBNAIL_ASSET_TYPE => {
|
||||||
existing_thumbnail_objects = document_asset_objects::table
|
existing_thumbnail_objects = document_asset_objects::table
|
||||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||||
.order(document_asset_objects::ordinal.asc())
|
.order(document_asset_objects::ordinal.asc())
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
@@ -386,6 +393,7 @@ fn load_thumbnail_context(
|
|||||||
PREVIEW_ASSET_TYPE => {
|
PREVIEW_ASSET_TYPE => {
|
||||||
existing_preview_objects = document_asset_objects::table
|
existing_preview_objects = document_asset_objects::table
|
||||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||||
.order(document_asset_objects::ordinal.asc())
|
.order(document_asset_objects::ordinal.asc())
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
@@ -569,7 +577,10 @@ fn persist_assets_metadata(
|
|||||||
context: &ThumbnailContext,
|
context: &ThumbnailContext,
|
||||||
assets: &[AssetPersistence],
|
assets: &[AssetPersistence],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let tenant_id = context.document.tenant_id;
|
||||||
|
let mut conn = state
|
||||||
|
.db_for_tenant(tenant_id)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
if let Some(existing_preview) = &context.existing_preview {
|
if let Some(existing_preview) = &context.existing_preview {
|
||||||
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
|
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
|
||||||
@@ -608,6 +619,7 @@ fn persist_assets_metadata(
|
|||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
}),
|
}),
|
||||||
cardinality: Some(object_count),
|
cardinality: Some(object_count),
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_assets::table)
|
diesel::insert_into(document_assets::table)
|
||||||
@@ -627,7 +639,8 @@ fn persist_assets_metadata(
|
|||||||
|
|
||||||
diesel::delete(
|
diesel::delete(
|
||||||
document_asset_objects::table
|
document_asset_objects::table
|
||||||
.filter(document_asset_objects::asset_id.eq(asset.asset_id)),
|
.filter(document_asset_objects::asset_id.eq(asset.asset_id))
|
||||||
|
.filter(document_asset_objects::tenant_id.eq(tenant_id)),
|
||||||
)
|
)
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
@@ -649,6 +662,7 @@ fn persist_assets_metadata(
|
|||||||
ordinal: object.ordinal,
|
ordinal: object.ordinal,
|
||||||
s3_key: object.s3_key.clone(),
|
s3_key: object.s3_key.clone(),
|
||||||
metadata: object_metadata,
|
metadata: object_metadata,
|
||||||
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_asset_objects::table)
|
diesel::insert_into(document_asset_objects::table)
|
||||||
@@ -667,11 +681,17 @@ fn persist_document_page_count(
|
|||||||
document_version_id: Uuid,
|
document_version_id: Uuid,
|
||||||
page_count: u32,
|
page_count: u32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?;
|
||||||
|
let tenant_id: Uuid = documents::table
|
||||||
|
.find(document_id)
|
||||||
|
.select(documents::tenant_id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let existing_metadata: Value = document_versions::table
|
let existing_metadata: Value = document_versions::table
|
||||||
.filter(document_versions::id.eq(document_version_id))
|
.filter(document_versions::id.eq(document_version_id))
|
||||||
.filter(document_versions::document_id.eq(document_id))
|
.filter(document_versions::document_id.eq(document_id))
|
||||||
|
.filter(document_versions::tenant_id.eq(tenant_id))
|
||||||
.select(document_versions::metadata)
|
.select(document_versions::metadata)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
@@ -691,7 +711,8 @@ fn persist_document_page_count(
|
|||||||
diesel::update(
|
diesel::update(
|
||||||
document_versions::table
|
document_versions::table
|
||||||
.filter(document_versions::id.eq(document_version_id))
|
.filter(document_versions::id.eq(document_version_id))
|
||||||
.filter(document_versions::document_id.eq(document_id)),
|
.filter(document_versions::document_id.eq(document_id))
|
||||||
|
.filter(document_versions::tenant_id.eq(tenant_id)),
|
||||||
)
|
)
|
||||||
.set(document_versions::metadata.eq(updated))
|
.set(document_versions::metadata.eq(updated))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use serde::Deserialize;
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthenticatedUser {
|
struct AuthenticatedUser {
|
||||||
username: String,
|
username: String,
|
||||||
role: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -27,7 +26,6 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
|||||||
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
assert_eq!(user.username, "alice");
|
assert_eq!(user.username, "alice");
|
||||||
assert_eq!(user.role, "admin");
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+283
-12
@@ -11,18 +11,20 @@ use axum::Router;
|
|||||||
use backend::auth::jwt::JwtService;
|
use backend::auth::jwt::JwtService;
|
||||||
use backend::config::AppConfig;
|
use backend::config::AppConfig;
|
||||||
use backend::db::{self, PgPool};
|
use backend::db::{self, PgPool};
|
||||||
use backend::models::{Job, NewUser};
|
use backend::models::{Job, NewUser, NewUserMembership, Tenant};
|
||||||
use backend::routes;
|
use backend::routes;
|
||||||
use backend::state::AppState;
|
use backend::state::AppState;
|
||||||
use backend::storage::ObjectStorage;
|
use backend::storage::ObjectStorage;
|
||||||
use diesel::connection::SimpleConnection;
|
use diesel::connection::SimpleConnection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel::OptionalExtension;
|
||||||
use diesel::PgConnection;
|
use diesel::PgConnection;
|
||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -138,6 +140,7 @@ impl TestApp {
|
|||||||
s3_bucket: "test-bucket".to_string(),
|
s3_bucket: "test-bucket".to_string(),
|
||||||
quickwit_endpoint: None,
|
quickwit_endpoint: None,
|
||||||
quickwit_index: None,
|
quickwit_index: None,
|
||||||
|
default_tenant_slug: "admin".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||||
@@ -149,16 +152,20 @@ impl TestApp {
|
|||||||
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
|
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
|
||||||
let router = routes::create_router(state.clone());
|
let router = routes::create_router(state.clone());
|
||||||
|
|
||||||
Ok(Self {
|
let app = Self {
|
||||||
state,
|
state,
|
||||||
router,
|
router,
|
||||||
storage,
|
storage,
|
||||||
})
|
};
|
||||||
|
|
||||||
|
app.ensure_default_tenant().await?;
|
||||||
|
|
||||||
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cleanup(&self) -> Result<()> {
|
pub async fn cleanup(&self) -> Result<()> {
|
||||||
let pool = self.state.pool.clone();
|
let pool = self.state.pool.clone();
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
let mut conn = pool
|
let mut conn = pool
|
||||||
.get()
|
.get()
|
||||||
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
|
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
|
||||||
@@ -166,7 +173,10 @@ impl TestApp {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.context("cleanup task panicked")?
|
.context("cleanup task panicked")?;
|
||||||
|
|
||||||
|
self.ensure_default_tenant().await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -174,27 +184,111 @@ impl TestApp {
|
|||||||
self.storage.clone()
|
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> {
|
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
|
||||||
let username = username.to_string();
|
let username = username.to_string();
|
||||||
let password = password.to_string();
|
let password = password.to_string();
|
||||||
let role = role.to_string();
|
let role = role.to_string();
|
||||||
|
let tenant_id = self
|
||||||
|
.state
|
||||||
|
.tenants
|
||||||
|
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
|
||||||
|
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
|
||||||
self.with_conn(move |conn| {
|
self.with_conn(move |conn| {
|
||||||
let password_hash = hash_password(&password)?;
|
let password_hash = hash_password(&password)?;
|
||||||
let user = NewUser {
|
let user = NewUser {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
role,
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(backend::schema::users::table)
|
diesel::insert_into(backend::schema::users::table)
|
||||||
.values(&user)
|
.values(&user)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.context("failed to insert user")?;
|
.context("failed to insert user")?;
|
||||||
|
|
||||||
|
let membership = NewUserMembership {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id: user.id,
|
||||||
|
tenant_id,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(backend::schema::user_memberships::table)
|
||||||
|
.values(&membership)
|
||||||
|
.execute(conn)
|
||||||
|
.context("failed to insert user membership")?;
|
||||||
Ok(user.id)
|
Ok(user.id)
|
||||||
})
|
})
|
||||||
.await
|
.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> {
|
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct LoginPayload<'a> {
|
struct LoginPayload<'a> {
|
||||||
@@ -217,11 +311,57 @@ impl TestApp {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct LoginResponse {
|
struct LoginResponse {
|
||||||
access_token: String,
|
access_token: String,
|
||||||
}
|
}
|
||||||
let parsed: LoginResponse = serde_json::from_slice(&body)?;
|
|
||||||
|
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) {
|
||||||
|
return Ok(parsed.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TenantSummary {
|
||||||
|
tenant_id: Uuid,
|
||||||
|
_slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TenantSelectionResponse {
|
||||||
|
access_token: String,
|
||||||
|
tenants: Vec<TenantSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
|
||||||
|
ensure!(
|
||||||
|
!selection.tenants.is_empty(),
|
||||||
|
"login returned no tenant options",
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SelectTenantPayload {
|
||||||
|
tenant_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_tenant = selection.tenants[0].tenant_id;
|
||||||
|
let select_response = self
|
||||||
|
.post_json(
|
||||||
|
"/api/auth/select-tenant",
|
||||||
|
&SelectTenantPayload {
|
||||||
|
tenant_id: target_tenant,
|
||||||
|
},
|
||||||
|
Some(&selection.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure!(
|
||||||
|
select_response.status() == StatusCode::OK,
|
||||||
|
"tenant selection failed with status {}",
|
||||||
|
select_response.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
let select_body = body_to_vec(select_response.into_body()).await?;
|
||||||
|
let parsed: LoginResponse = serde_json::from_slice(&select_body)?;
|
||||||
Ok(parsed.access_token)
|
Ok(parsed.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +478,60 @@ impl TestApp {
|
|||||||
data: &[u8],
|
data: &[u8],
|
||||||
folder_id: Option<Uuid>,
|
folder_id: Option<Uuid>,
|
||||||
token: &str,
|
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>> {
|
) -> Result<hyper::Response<Body>> {
|
||||||
let boundary = format!("boundary-{}", Uuid::new_v4());
|
let boundary = format!("boundary-{}", Uuid::new_v4());
|
||||||
let mut body = Vec::new();
|
let mut body = Vec::new();
|
||||||
@@ -360,6 +554,46 @@ impl TestApp {
|
|||||||
body.extend(b"\r\n");
|
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());
|
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
||||||
|
|
||||||
let builder = Request::builder()
|
let builder = Request::builder()
|
||||||
@@ -380,7 +614,7 @@ impl TestApp {
|
|||||||
.expect("infallible response"))
|
.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
|
where
|
||||||
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
@@ -397,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, ()> {
|
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
|
||||||
DB_LOCK.lock().await
|
DB_LOCK.lock().await
|
||||||
}
|
}
|
||||||
@@ -426,13 +682,28 @@ async fn prepare_database(pool: &PgPool) -> Result<()> {
|
|||||||
|
|
||||||
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||||
conn.batch_execute(
|
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")?;
|
.context("failed to truncate tables")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_password(password: &str) -> Result<String> {
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
use argon2::password_hash::{PasswordHasher, SaltString};
|
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||||
use argon2::Argon2;
|
use argon2::Argon2;
|
||||||
|
|
||||||
|
|||||||
+349
-93
@@ -2,7 +2,7 @@ mod common;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::http::StatusCode;
|
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 serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ struct DocumentDetail {
|
|||||||
struct DocumentInfo {
|
struct DocumentInfo {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
title: String,
|
title: String,
|
||||||
|
filename: String,
|
||||||
original_name: String,
|
original_name: String,
|
||||||
deleted_at: Option<String>,
|
deleted_at: Option<String>,
|
||||||
issued_at: Option<String>,
|
issued_at: Option<String>,
|
||||||
@@ -167,7 +168,14 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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 body = body_to_vec(upload.into_body()).await?;
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
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_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
||||||
assert!(current_version.assets.is_empty());
|
assert!(current_version.assets.is_empty());
|
||||||
|
|
||||||
|
let storage_key = app.storage_key_for(¤t_version.s3_key).await?;
|
||||||
let stored = app
|
let stored = app
|
||||||
.storage()
|
.storage()
|
||||||
.get(¤t_version.s3_key)
|
.get(&storage_key)
|
||||||
.await
|
.await
|
||||||
.expect("object stored");
|
.expect("object stored");
|
||||||
assert_eq!(stored.bytes, file_bytes);
|
assert_eq!(stored.bytes, file_bytes);
|
||||||
assert_eq!(app.storage().object_count().await, 1);
|
assert_eq!(app.storage().object_count().await, 1);
|
||||||
|
|
||||||
let response = app.get("/api/documents", Some(&token)).await?;
|
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 body = body_to_vec(response.into_body()).await?;
|
||||||
let mut list: Vec<DocumentListItem> = serde_json::from_slice(&body)?;
|
let mut list: Vec<DocumentListItem> = serde_json::from_slice(&body)?;
|
||||||
assert_eq!(list.len(), 1);
|
assert_eq!(list.len(), 1);
|
||||||
@@ -220,7 +236,14 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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 body = body_to_vec(download.into_body()).await?;
|
||||||
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
|
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
|
||||||
assert!(download_info.url.contains(¤t_version.s3_key));
|
assert!(download_info.url.contains(¤t_version.s3_key));
|
||||||
@@ -239,6 +262,50 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn duplicate_and_restore_document() -> Result<()> {
|
async fn duplicate_and_restore_document() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -259,7 +326,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(first.into_body()).await?;
|
||||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
@@ -273,7 +347,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(second.into_body()).await?;
|
||||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
@@ -306,7 +387,14 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(third.into_body()).await?;
|
||||||
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
|
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
|
||||||
|
|
||||||
@@ -319,92 +407,127 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bulk_reanalyze_documents() -> Result<()> {
|
async fn upload_skips_existing_when_requested() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let password = "bulkpass";
|
let password = "skip-doc";
|
||||||
app.insert_user("alex", password, "admin").await?;
|
app.insert_user("skip", password, "admin").await?;
|
||||||
let token = app.login_token("alex", password).await?;
|
let token = app.login_token("skip", password).await?;
|
||||||
|
|
||||||
app.clear_jobs().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 first_bytes = b"first doc";
|
let payload = b"identical document payload";
|
||||||
let first = app
|
let primary_tag_ids = format!("[\"{}\"]", primary_tag.id);
|
||||||
.upload_document(
|
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",
|
"/api/documents",
|
||||||
"first.txt",
|
"original.pdf",
|
||||||
"text/plain",
|
"application/pdf",
|
||||||
first_bytes,
|
payload,
|
||||||
None,
|
None,
|
||||||
|
extras,
|
||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(first.status(), StatusCode::CREATED);
|
{
|
||||||
let first_body = body_to_vec(first.into_body()).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 first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
let second_bytes = b"second doc";
|
let alt_tag_payload = CreateTagPayload {
|
||||||
let second = app
|
label: "alternate",
|
||||||
.upload_document(
|
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",
|
"/api/documents",
|
||||||
"second.txt",
|
"ignored.pdf",
|
||||||
"text/plain",
|
"application/pdf",
|
||||||
second_bytes,
|
payload,
|
||||||
None,
|
None,
|
||||||
|
skip_extras,
|
||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(second.status(), StatusCode::CREATED);
|
assert_eq!(skip_resp.status(), StatusCode::NO_CONTENT);
|
||||||
let second_body = body_to_vec(second.into_body()).await?;
|
|
||||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
|
||||||
|
|
||||||
app.clear_jobs().await?;
|
let fetch = app
|
||||||
|
.get(
|
||||||
let response = app
|
&format!("/api/documents/{}", first_detail.document.id),
|
||||||
.post_json(
|
|
||||||
"/api/documents/reanalyze",
|
|
||||||
&serde_json::json!({}),
|
|
||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
{
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let status = fetch.status();
|
||||||
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
|
assert!(
|
||||||
assert_eq!(bulk.queued, 2);
|
status == StatusCode::OK
|
||||||
|
|| status == StatusCode::CREATED
|
||||||
let jobs = app.jobs_by_type("analyze-document").await?;
|
|| status == StatusCode::NO_CONTENT
|
||||||
assert_eq!(jobs.len(), 2);
|
);
|
||||||
let mut payload_docs = Vec::new();
|
|
||||||
for job in jobs {
|
|
||||||
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
|
|
||||||
assert!(payload.force);
|
|
||||||
payload_docs.push((payload.document_id, payload.document_version_id));
|
|
||||||
}
|
}
|
||||||
|
let fetch_body = body_to_vec(fetch.into_body()).await?;
|
||||||
|
let fetched: DocumentDetail = serde_json::from_slice(&fetch_body)?;
|
||||||
|
|
||||||
let mut expected = vec![
|
assert_eq!(fetched.document.id, first_detail.document.id);
|
||||||
(
|
assert_eq!(fetched.document.title, first_detail.document.title);
|
||||||
first_detail.document.id,
|
assert_eq!(fetched.document.tags.len(), 1);
|
||||||
first_detail
|
assert_eq!(fetched.document.tags[0].label, "primary");
|
||||||
.document
|
|
||||||
.current_version
|
|
||||||
.as_ref()
|
|
||||||
.expect("first current version")
|
|
||||||
.id,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
second_detail.document.id,
|
|
||||||
second_detail
|
|
||||||
.document
|
|
||||||
.current_version
|
|
||||||
.as_ref()
|
|
||||||
.expect("second current version")
|
|
||||||
.id,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
payload_docs.sort();
|
|
||||||
expected.sort();
|
|
||||||
assert_eq!(payload_docs, expected);
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -429,7 +552,14 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(alpha.into_body()).await?;
|
||||||
let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?;
|
let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?;
|
||||||
|
|
||||||
@@ -443,7 +573,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(beta.into_body()).await?;
|
||||||
let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?;
|
let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?;
|
||||||
|
|
||||||
@@ -457,7 +590,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(folder_resp.into_body()).await?;
|
||||||
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
||||||
|
|
||||||
@@ -471,8 +607,14 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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?;
|
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)?;
|
let result: BulkMoveResult = serde_json::from_slice(&move_body)?;
|
||||||
assert_eq!(result.updated, 2);
|
assert_eq!(result.updated, 2);
|
||||||
|
|
||||||
@@ -482,7 +624,10 @@ async fn bulk_move_documents_to_folder() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(folder_contents.into_body()).await?;
|
||||||
let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?;
|
let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?;
|
||||||
let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect();
|
let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect();
|
||||||
@@ -520,7 +665,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(first.into_body()).await?;
|
||||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
@@ -534,7 +686,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(second.into_body()).await?;
|
||||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
@@ -548,7 +707,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(urgent_tag.into_body()).await?;
|
||||||
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
|
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
|
||||||
|
|
||||||
@@ -562,7 +728,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(review_tag.into_body()).await?;
|
||||||
let review: TagResponse = serde_json::from_slice(&review_body)?;
|
let review: TagResponse = serde_json::from_slice(&review_body)?;
|
||||||
|
|
||||||
@@ -577,7 +750,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(add_resp.into_body()).await?;
|
||||||
let add_result: BulkTagResult = serde_json::from_slice(&add_body)?;
|
let add_result: BulkTagResult = serde_json::from_slice(&add_body)?;
|
||||||
assert_eq!(add_result.added, 4);
|
assert_eq!(add_result.added, 4);
|
||||||
@@ -586,7 +766,10 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
let refreshed = app
|
let refreshed = app
|
||||||
.get(&format!("/api/documents/{}", doc_id), Some(&token))
|
.get(&format!("/api/documents/{}", doc_id), Some(&token))
|
||||||
.await?;
|
.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 refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||||
let labels: Vec<_> = detail
|
let labels: Vec<_> = detail
|
||||||
@@ -610,7 +793,14 @@ async fn bulk_update_tags_for_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(remove_resp.into_body()).await?;
|
||||||
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
|
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
|
||||||
assert_eq!(remove_result.removed, 2);
|
assert_eq!(remove_result.removed, 2);
|
||||||
@@ -654,7 +844,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(first.into_body()).await?;
|
||||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
@@ -668,7 +865,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
&token,
|
&token,
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(second.into_body()).await?;
|
||||||
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
@@ -679,7 +883,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(sender.into_body()).await?;
|
||||||
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
|
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
|
||||||
|
|
||||||
@@ -690,7 +901,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(receiver.into_body()).await?;
|
||||||
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
|
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
|
||||||
|
|
||||||
@@ -718,7 +936,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(assign_resp.into_body()).await?;
|
||||||
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
|
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
|
||||||
assert_eq!(assign_result.assigned, 4);
|
assert_eq!(assign_result.assigned, 4);
|
||||||
@@ -728,7 +953,10 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
let refreshed = app
|
let refreshed = app
|
||||||
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
.get(&format!("/api/documents/{doc_id}"), Some(&token))
|
||||||
.await?;
|
.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 refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||||
assert_eq!(detail.document.correspondents.len(), 2);
|
assert_eq!(detail.document.correspondents.len(), 2);
|
||||||
@@ -751,7 +979,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(duplicate_resp.into_body()).await?;
|
||||||
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
|
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
|
||||||
assert_eq!(duplicate_result.assigned, 0);
|
assert_eq!(duplicate_result.assigned, 0);
|
||||||
@@ -764,7 +999,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(replacement.into_body()).await?;
|
||||||
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
|
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
|
||||||
|
|
||||||
@@ -788,7 +1030,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(replace_resp.into_body()).await?;
|
||||||
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
|
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
|
||||||
assert_eq!(replace_result.assigned, 2);
|
assert_eq!(replace_result.assigned, 2);
|
||||||
@@ -834,7 +1083,14 @@ async fn bulk_assign_correspondents_to_selection() -> Result<()> {
|
|||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.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_body = body_to_vec(remove_resp.into_body()).await?;
|
||||||
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
|
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
|
||||||
assert_eq!(remove_result.assigned, 0);
|
assert_eq!(remove_result.assigned, 0);
|
||||||
|
|||||||
+189
-14
@@ -5,6 +5,7 @@ use axum::http::StatusCode;
|
|||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -16,6 +17,7 @@ struct FolderResponse {
|
|||||||
struct FolderInfo {
|
struct FolderInfo {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -42,14 +44,6 @@ struct EnsureFolderPath<'a> {
|
|||||||
segments: &'a [&'a str],
|
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)]
|
#[derive(Serialize)]
|
||||||
struct MoveDocumentRequest {
|
struct MoveDocumentRequest {
|
||||||
folder_id: Option<Uuid>,
|
folder_id: Option<Uuid>,
|
||||||
@@ -150,6 +144,78 @@ async fn folder_move_and_delete_flow() -> Result<()> {
|
|||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn ensure_path_creates_nested_folders() -> Result<()> {
|
async fn ensure_path_creates_nested_folders() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -166,14 +232,14 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
|||||||
let first_resp = app
|
let first_resp = app
|
||||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||||
.await?;
|
.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_body = body_to_vec(first_resp.into_body()).await?;
|
||||||
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
let second_resp = app
|
let second_resp = app
|
||||||
.post_json("/api/folders/path", &base_path, Some(&token))
|
.post_json("/api/folders/path", &base_path, Some(&token))
|
||||||
.await?;
|
.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_body = body_to_vec(second_resp.into_body()).await?;
|
||||||
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
|
||||||
assert_eq!(second_folder.folder.id, first_folder.folder.id);
|
assert_eq!(second_folder.folder.id, first_folder.folder.id);
|
||||||
@@ -227,6 +293,118 @@ async fn ensure_path_creates_nested_folders() -> Result<()> {
|
|||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -267,10 +445,7 @@ async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
|
|||||||
let rename_resp = app
|
let rename_resp = app
|
||||||
.patch_json(
|
.patch_json(
|
||||||
&format!("/api/folders/{}", parent.folder.id),
|
&format!("/api/folders/{}", parent.folder.id),
|
||||||
&UpdateFolderRequest {
|
&json!({ "name": "Archive" }),
|
||||||
parent_id: None,
|
|
||||||
name: Some("Archive".to_string()),
|
|
||||||
},
|
|
||||||
Some(&token),
|
Some(&token),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
+103
-7
@@ -2,11 +2,23 @@ mod common;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::http::StatusCode;
|
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::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateTagPayload<'a> {
|
||||||
|
label: &'a str,
|
||||||
|
color: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentDetail {
|
struct DocumentDetail {
|
||||||
document: DocumentInfo,
|
document: DocumentInfo,
|
||||||
@@ -61,12 +73,6 @@ async fn tag_assignment_flow() -> Result<()> {
|
|||||||
let upload_body = body_to_vec(upload.into_body()).await?;
|
let upload_body = body_to_vec(upload.into_body()).await?;
|
||||||
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
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
|
let create_tag = app
|
||||||
.post_json(
|
.post_json(
|
||||||
"/api/tags",
|
"/api/tags",
|
||||||
@@ -172,3 +178,93 @@ async fn tag_assignment_flow() -> Result<()> {
|
|||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
+33
-14
@@ -10,19 +10,19 @@ services:
|
|||||||
tmpfs:
|
tmpfs:
|
||||||
- /var/lib/postgresql/data
|
- /var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U papercrate"]
|
test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate_test"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
quickwit:
|
quickwit-test:
|
||||||
image: quickwit/quickwit:0.8.2
|
image: quickwit/quickwit:0.8.2
|
||||||
command: ["run"]
|
command: ["run"]
|
||||||
environment:
|
environment:
|
||||||
QW_ENABLE_API_AUTH: "false"
|
QW_ENABLE_API_AUTH: "false"
|
||||||
QW_DATA_DIR: /quickwit/data
|
QW_DATA_DIR: /quickwit/data
|
||||||
ports:
|
ports:
|
||||||
- "7280:7280"
|
- "7281:7280"
|
||||||
volumes:
|
volumes:
|
||||||
- quickwit_test_data:/quickwit/data
|
- quickwit_test_data:/quickwit/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -31,21 +31,40 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
quickwit-create-index:
|
admin-bootstrap:
|
||||||
image: quickwit/quickwit:0.8.2
|
build:
|
||||||
|
context: ./backend
|
||||||
depends_on:
|
depends_on:
|
||||||
quickwit:
|
postgres-test:
|
||||||
|
condition: service_healthy
|
||||||
|
quickwit-test:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
|
||||||
- quickwit_test_data:/quickwit/data
|
|
||||||
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro
|
|
||||||
environment:
|
environment:
|
||||||
QW_DATA_DIR: /quickwit/data
|
DATABASE_URL: postgres://papercrate:papercrate_test@postgres-test:5432/papercrate_test
|
||||||
QW_NODE_URI: http://quickwit:7280
|
DATABASE_MAX_POOL_SIZE: 1
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
S3_BUCKET: documents
|
||||||
|
JWT_SECRET: change-me-super-secret
|
||||||
|
QUICKWIT_ENDPOINT: http://quickwit-test:7280
|
||||||
|
DEFAULT_TENANT_SLUG: admin
|
||||||
|
entrypoint: []
|
||||||
command: >
|
command: >
|
||||||
quickwit index list --node-uri http://quickwit:7280 2>/dev/null | grep -q '"documents"' \
|
/bin/sh -c "
|
||||||
|| quickwit index create --index-config /tmp/documents-index.yaml --node-uri http://quickwit:7280
|
echo 'Running database migrations' &&
|
||||||
|
diesel migration run &&
|
||||||
|
echo 'Ensuring tenant admin exists' &&
|
||||||
|
if papercrate-admin list-tenants | grep -q '^admin '; then
|
||||||
|
echo 'tenant admin already exists';
|
||||||
|
else
|
||||||
|
papercrate-admin create-tenant admin;
|
||||||
|
fi &&
|
||||||
|
echo 'Ensuring demo user credentials' &&
|
||||||
|
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
||||||
|
echo 'Ensuring demo membership' &&
|
||||||
|
papercrate-admin add-user-to-tenant admin admin admin &&
|
||||||
|
echo 'Ensuring Quickwit index for admin tenant' &&
|
||||||
|
papercrate-admin quickwit-create-index admin
|
||||||
|
"
|
||||||
|
user: root
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+35
-6
@@ -62,15 +62,44 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
quickwit-create-index:
|
admin-bootstrap:
|
||||||
image: quickwit/quickwit:0.8.2
|
build:
|
||||||
|
context: ./backend
|
||||||
depends_on:
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
quickwit:
|
quickwit:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
environment:
|
||||||
- quickwit_data:/quickwit/data
|
DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate
|
||||||
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro
|
DATABASE_MAX_POOL_SIZE: 2
|
||||||
command: index create --endpoint=http://quickwit:7280 --index-config /tmp/documents-index.yaml
|
AWS_ENDPOINT_URL: http://minio:9000
|
||||||
|
AWS_ACCESS_KEY_ID: minioadmin
|
||||||
|
AWS_SECRET_ACCESS_KEY: minioadmin
|
||||||
|
AWS_REGION: us-east-1
|
||||||
|
S3_BUCKET: documents
|
||||||
|
JWT_SECRET: change-me-super-secret
|
||||||
|
QUICKWIT_ENDPOINT: http://quickwit:7280
|
||||||
|
DEFAULT_TENANT_SLUG: admin
|
||||||
|
entrypoint: []
|
||||||
|
command: >
|
||||||
|
/bin/sh -c "
|
||||||
|
echo 'Running database migrations' &&
|
||||||
|
diesel migration run &&
|
||||||
|
echo 'Ensuring tenant admin exists' &&
|
||||||
|
if papercrate-admin list-tenants | grep -q '^admin '; then
|
||||||
|
echo 'tenant admin already exists';
|
||||||
|
else
|
||||||
|
papercrate-admin create-tenant admin;
|
||||||
|
fi &&
|
||||||
|
echo 'Ensuring demo user credentials' &&
|
||||||
|
(papercrate-admin create-user admin adminadmin || papercrate-admin set-password admin adminadmin) &&
|
||||||
|
echo 'Ensuring demo membership' &&
|
||||||
|
papercrate-admin add-user-to-tenant admin admin admin &&
|
||||||
|
echo 'Ensuring Quickwit index for admin tenant' &&
|
||||||
|
papercrate-admin quickwit-create-index admin
|
||||||
|
"
|
||||||
|
user: root
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+7
-5
@@ -5,8 +5,8 @@ Unless noted otherwise, endpoints below require a valid `Authorization: Bearer <
|
|||||||
|
|
||||||
Authentication
|
Authentication
|
||||||
--------------
|
--------------
|
||||||
- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public).
|
- 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).
|
- 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.
|
- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie.
|
||||||
- GET /api/auth/me - Return the authenticated principal payload.
|
- GET /api/auth/me - Return the authenticated principal payload.
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ Health
|
|||||||
|
|
||||||
Documents
|
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.
|
- 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.
|
||||||
- POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields).
|
- 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/reanalyze - Queue re-analysis for every non-deleted document.
|
- 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/move - Move multiple documents to a target folder.
|
||||||
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
- 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.
|
- 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.
|
||||||
@@ -47,9 +47,11 @@ Folders
|
|||||||
-------
|
-------
|
||||||
- POST /api/folders - Create a folder (optionally under a parent).
|
- POST /api/folders - Create a folder (optionally under a parent).
|
||||||
- POST /api/folders/path - Ensure a nested folder path exists, creating missing segments.
|
- 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.
|
- 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.
|
- DELETE /api/folders/:id - Soft-delete a folder.
|
||||||
- PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`).
|
- 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
|
Tags
|
||||||
----
|
----
|
||||||
|
|||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "papercrate-frontend",
|
"name": "papercrate-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tabler/icons-react": "3.11.0",
|
"@tabler/icons-react": "3.11.0",
|
||||||
"axios": "1.7.7",
|
"axios": "1.7.7",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
@@ -1852,6 +1853,15 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/inter": {
|
||||||
|
"version": "5.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||||
|
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "echo \"No linting configured\""
|
"lint": "echo \"No linting configured\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@tabler/icons-react": "3.11.0",
|
"@tabler/icons-react": "3.11.0",
|
||||||
"axios": "1.7.7",
|
"axios": "1.7.7",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"@babel/core": "7.26.0",
|
"@babel/core": "7.26.0",
|
||||||
"@babel/preset-env": "7.26.0",
|
"@babel/preset-env": "7.26.0",
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "7.26.3",
|
||||||
|
"@svgr/webpack": "8.1.0",
|
||||||
"babel-loader": "9.2.1",
|
"babel-loader": "9.2.1",
|
||||||
"css-loader": "7.1.2",
|
"css-loader": "7.1.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
@@ -26,7 +28,6 @@
|
|||||||
"style-loader": "4.0.0",
|
"style-loader": "4.0.0",
|
||||||
"webpack": "5.95.0",
|
"webpack": "5.95.0",
|
||||||
"webpack-cli": "5.1.4",
|
"webpack-cli": "5.1.4",
|
||||||
"webpack-dev-server": "5.1.0",
|
"webpack-dev-server": "5.1.0"
|
||||||
"@svgr/webpack": "8.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,11 +118,11 @@ function CorrespondentsPanel({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="correspondents-panel column">
|
<section className="correspondents-panel">
|
||||||
<div className="column-header">
|
<div className="panel-section__header">
|
||||||
<div className="column-header__titles">
|
<div className="panel-section__titles">
|
||||||
<h2>Correspondents</h2>
|
<h2>Correspondents</h2>
|
||||||
<div className="column-subtitle">{correspondents.length} total</div>
|
<div className="panel-section__subtitle">{correspondents.length} total</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions correspondents-actions">
|
<div className="header-actions correspondents-actions">
|
||||||
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
<form className="correspondents-actions__form" onSubmit={handleCreate}>
|
||||||
@@ -147,7 +147,7 @@ function CorrespondentsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="column-body tags-panel__body">
|
<div className="panel-section__body tags-panel__body">
|
||||||
{correspondents.length === 0 ? (
|
{correspondents.length === 0 ? (
|
||||||
<div className="empty-state">No correspondents created yet.</div>
|
<div className="empty-state">No correspondents created yet.</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => {
|
||||||
|
if (value < min) return min;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureDocumentRoot = () => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return document.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PreviewZoomOverlay = ({
|
||||||
|
open = false,
|
||||||
|
display = null,
|
||||||
|
onClose = noop,
|
||||||
|
}) => {
|
||||||
|
const portalTarget = ensureDocumentRoot();
|
||||||
|
const [isNativeScale, setIsNativeScale] = useState(false);
|
||||||
|
const [naturalSize, setNaturalSize] = useState({ width: null, height: null });
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const imageRef = useRef(null);
|
||||||
|
const focusRef = useRef(null);
|
||||||
|
const previouslyFocusedRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsNativeScale(false);
|
||||||
|
setNaturalSize({ width: null, height: null });
|
||||||
|
focusRef.current = null;
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (scrollEl) {
|
||||||
|
scrollEl.scrollLeft = 0;
|
||||||
|
scrollEl.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !isNativeScale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
const imageEl = imageRef.current;
|
||||||
|
if (!scrollEl || !imageEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageWidth = imageEl.naturalWidth || imageEl.clientWidth;
|
||||||
|
const imageHeight = imageEl.naturalHeight || imageEl.clientHeight;
|
||||||
|
if (!(imageWidth > 0 && imageHeight > 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 };
|
||||||
|
const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth);
|
||||||
|
const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight);
|
||||||
|
|
||||||
|
const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2;
|
||||||
|
const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2;
|
||||||
|
|
||||||
|
scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft);
|
||||||
|
scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop);
|
||||||
|
}, [open, isNativeScale, naturalSize.width, naturalSize.height]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||||
|
previouslyFocusedRef.current.focus();
|
||||||
|
}
|
||||||
|
previouslyFocusedRef.current = null;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && typeof active.focus === 'function') {
|
||||||
|
previouslyFocusedRef.current = active;
|
||||||
|
} else {
|
||||||
|
previouslyFocusedRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
scrollEl.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') {
|
||||||
|
previouslyFocusedRef.current.focus();
|
||||||
|
previouslyFocusedRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
if (display?.canGoPrev && display?.goPrev) {
|
||||||
|
event.preventDefault();
|
||||||
|
display.goPrev();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight') {
|
||||||
|
if (display?.canGoNext && display?.goNext) {
|
||||||
|
event.preventDefault();
|
||||||
|
display.goNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open || !display?.url || !portalTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navVisible = Boolean(display?.canGoPrev || display?.canGoNext);
|
||||||
|
const stageClassName = [
|
||||||
|
'preview-zoom__stage',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const containerClassName = [
|
||||||
|
'preview-zoom__scroll',
|
||||||
|
isNativeScale ? 'preview-zoom__scroll--native' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const imageStyle = isNativeScale
|
||||||
|
? {
|
||||||
|
cursor: 'zoom-out',
|
||||||
|
width: naturalSize.width ? `${naturalSize.width}px` : 'auto',
|
||||||
|
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
||||||
|
maxWidth: 'none',
|
||||||
|
maxHeight: 'none',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
cursor: 'zoom-in',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
maxHeight: '95vh',
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
className="preview-zoom-backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Enlarged document preview"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={stageClassName}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={containerClassName}
|
||||||
|
ref={scrollRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={display.url}
|
||||||
|
alt={display.alt || 'Document preview'}
|
||||||
|
className="preview-zoom__image"
|
||||||
|
ref={imageRef}
|
||||||
|
draggable={false}
|
||||||
|
onLoad={(event) => {
|
||||||
|
setNaturalSize({
|
||||||
|
width: event.currentTarget.naturalWidth || null,
|
||||||
|
height: event.currentTarget.naturalHeight || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!isNativeScale) {
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (img) {
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
|
||||||
|
const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
||||||
|
focusRef.current = {
|
||||||
|
xRatio: clamp(xRatio, 0, 1),
|
||||||
|
yRatio: clamp(yRatio, 0, 1),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
focusRef.current = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
focusRef.current = null;
|
||||||
|
}
|
||||||
|
setIsNativeScale((current) => !current);
|
||||||
|
}}
|
||||||
|
style={imageStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{navVisible ? (
|
||||||
|
<div className="preview-zoom__nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-zoom__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (display?.canGoPrev && display?.goPrev) {
|
||||||
|
display.goPrev();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
disabled={!display?.canGoPrev}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-zoom__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (display?.canGoNext && display?.goNext) {
|
||||||
|
display.goNext();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Next preview"
|
||||||
|
disabled={!display?.canGoNext}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
portalTarget,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PreviewZoomOverlay;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
|
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
|
||||||
|
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
const DEFAULT_GRID_ICON_SIZE = 96;
|
const DEFAULT_GRID_ICON_SIZE = 96;
|
||||||
@@ -13,13 +13,62 @@ const getPageCount = (doc) =>
|
|||||||
? doc.current_version.metadata.page_count
|
? doc.current_version.metadata.page_count
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Detects when an element becomes visible within a scroll container.
|
||||||
|
const useLazyVisibility = (rootRef, resetKey) => {
|
||||||
|
const targetRef = useRef(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
const rootNode = rootRef?.current || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const element = targetRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||||
|
setIsVisible(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: rootNode,
|
||||||
|
rootMargin: '200px 0px',
|
||||||
|
threshold: 0.01,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isVisible, rootNode, resetKey]);
|
||||||
|
|
||||||
|
return { ref: targetRef, isVisible };
|
||||||
|
};
|
||||||
|
|
||||||
const DocumentThumbnailImage = ({
|
const DocumentThumbnailImage = ({
|
||||||
document,
|
document,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
alt,
|
alt,
|
||||||
maxSize = LIST_ICON_SIZE,
|
maxSize = LIST_ICON_SIZE,
|
||||||
|
scrollRootRef = null,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id);
|
||||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||||
const thumbnailAsset = useMemo(
|
const thumbnailAsset = useMemo(
|
||||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||||
@@ -45,14 +94,15 @@ const DocumentThumbnailImage = ({
|
|||||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||||
[dimensions.height, dimensions.width],
|
[dimensions.height, dimensions.width],
|
||||||
);
|
);
|
||||||
const url = useMemo(
|
const url = useMemo(() => {
|
||||||
() =>
|
if (!isVisible) {
|
||||||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
return null;
|
||||||
ensureAssetUrl,
|
}
|
||||||
getAsset: getDocumentAsset,
|
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||||
}),
|
ensureAssetUrl,
|
||||||
[document, ensureAssetUrl, getDocumentAsset],
|
getAsset: getDocumentAsset,
|
||||||
);
|
});
|
||||||
|
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||||
|
|
||||||
const pageCount = getPageCount(document);
|
const pageCount = getPageCount(document);
|
||||||
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
||||||
@@ -62,13 +112,15 @@ const DocumentThumbnailImage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="document-thumbnail-wrapper">
|
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||||
{url ? (
|
{url ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={alt || ''}
|
alt={alt || ''}
|
||||||
className="document-thumbnail"
|
className="document-thumbnail"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
/>
|
/>
|
||||||
@@ -125,6 +177,7 @@ const DocumentsTable = ({
|
|||||||
viewMode = 'list',
|
viewMode = 'list',
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
|
showHeader = true,
|
||||||
}) => {
|
}) => {
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
@@ -142,6 +195,18 @@ const DocumentsTable = ({
|
|||||||
[draggingDocumentIds],
|
[draggingDocumentIds],
|
||||||
);
|
);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
|
const [, forceVisibilityTick] = useState(0);
|
||||||
|
const lastScrollNodeRef = useRef(null);
|
||||||
|
const assignScrollRef = useCallback((node) => {
|
||||||
|
if (lastScrollNodeRef.current === node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastScrollNodeRef.current = node;
|
||||||
|
scrollRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
forceVisibilityTick((value) => value + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const isGridView = viewMode === 'grid';
|
const isGridView = viewMode === 'grid';
|
||||||
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
const handleSetViewMode = useCallback(
|
const handleSetViewMode = useCallback(
|
||||||
@@ -156,6 +221,11 @@ const DocumentsTable = ({
|
|||||||
},
|
},
|
||||||
[onViewModeChange],
|
[onViewModeChange],
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [viewMode]);
|
||||||
const isTagDragEvent = useCallback((event) => {
|
const isTagDragEvent = useCallback((event) => {
|
||||||
const types = Array.from(event.dataTransfer?.types || []);
|
const types = Array.from(event.dataTransfer?.types || []);
|
||||||
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
||||||
@@ -263,16 +333,6 @@ const DocumentsTable = ({
|
|||||||
[isTagDragEvent, onDocumentTagDrop],
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGridBackgroundClick = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (event.target !== event.currentTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClearSelection?.();
|
|
||||||
},
|
|
||||||
[onClearSelection],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
||||||
const showListSearchEmptyState =
|
const showListSearchEmptyState =
|
||||||
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
||||||
@@ -282,73 +342,53 @@ const DocumentsTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
>
|
>
|
||||||
<div className="column-header">
|
{showHeader ? (
|
||||||
<div className="column-header__titles">
|
<div className="panel-section__header">
|
||||||
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
<div className="panel-section__titles">
|
||||||
{breadcrumbs.map((crumb, index) => {
|
<h2>{currentFolderName}</h2>
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
{showingSearchResults && (
|
||||||
return (
|
<div className="panel-section__subtitle">Search results</div>
|
||||||
<span key={crumb.id} className="breadcrumb-item">
|
)}
|
||||||
{isLast ? (
|
</div>
|
||||||
<span className="breadcrumb-current">{crumb.name}</span>
|
<div className="header-actions">
|
||||||
) : (
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
<a
|
<button
|
||||||
href="#"
|
type="button"
|
||||||
onClick={(event) => {
|
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||||
event.preventDefault();
|
onClick={() => handleSetViewMode('list')}
|
||||||
onFolderSelect(crumb.id);
|
aria-pressed={!isGridView}
|
||||||
}}
|
title="List view"
|
||||||
>
|
>
|
||||||
{crumb.name}
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
</a>
|
</button>
|
||||||
)}
|
<button
|
||||||
{!isLast && <span className="breadcrumb-separator">›</span>}
|
type="button"
|
||||||
</span>
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
);
|
onClick={() => handleSetViewMode('grid')}
|
||||||
})}
|
aria-pressed={isGridView}
|
||||||
</nav>
|
title="Icons view"
|
||||||
{showingSearchResults && (
|
>
|
||||||
<div className="column-subtitle">Search results</div>
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
|
||||||
<div className="view-toggle" role="group" aria-label="Change view">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
onClick={onRequestCreateFolder}
|
||||||
onClick={() => handleSetViewMode('list')}
|
disabled={creatingFolder}
|
||||||
aria-pressed={!isGridView}
|
|
||||||
title="List view"
|
|
||||||
>
|
>
|
||||||
<ViewListIcon className="view-toggle__icon" size={18} />
|
{creatingFolder ? 'Creating…' : 'New folder'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="secondary" onClick={onRefresh}>
|
||||||
type="button"
|
Refresh
|
||||||
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
</button>
|
||||||
onClick={() => handleSetViewMode('grid')}
|
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
||||||
aria-pressed={isGridView}
|
Desk View
|
||||||
title="Icons view"
|
|
||||||
>
|
|
||||||
<ViewGridIcon className="view-toggle__icon" size={18} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRequestCreateFolder}
|
|
||||||
disabled={creatingFolder}
|
|
||||||
>
|
|
||||||
{creatingFolder ? 'Creating…' : 'New folder'}
|
|
||||||
</button>
|
|
||||||
<button className="secondary" onClick={onRefresh}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button className="secondary" type="button" onClick={onShowSkeuoWorkspace}>
|
|
||||||
Desk View
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
{showDefaultEmptyState && (
|
{showDefaultEmptyState && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
Drop files anywhere or onto a folder to upload documents.
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
@@ -359,11 +399,10 @@ const DocumentsTable = ({
|
|||||||
No documents match the current filters.
|
No documents match the current filters.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="column-body">
|
<div className="panel-section__body">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={assignScrollRef}
|
||||||
className="documents-scroll"
|
className="documents-scroll"
|
||||||
tabIndex={0}
|
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
if (event.target === scrollRef.current) {
|
if (event.target === scrollRef.current) {
|
||||||
onDocumentListFocus?.();
|
onDocumentListFocus?.();
|
||||||
@@ -377,13 +416,22 @@ const DocumentsTable = ({
|
|||||||
onDocumentListKeyDown(event);
|
onDocumentListKeyDown(event);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
>
|
>
|
||||||
{showDefaultEmptyState ? null : isGridView ? (
|
{showDefaultEmptyState ? null : isGridView ? (
|
||||||
<div
|
<div
|
||||||
className="documents-grid"
|
className="documents-grid"
|
||||||
role="list"
|
role="list"
|
||||||
onClick={handleGridBackgroundClick}
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||||
>
|
>
|
||||||
{!showingSearchResults &&
|
{!showingSearchResults &&
|
||||||
@@ -478,6 +526,7 @@ const DocumentsTable = ({
|
|||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||||
maxSize={gridIconSize}
|
maxSize={gridIconSize}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
/>
|
/>
|
||||||
<div className="document-card__meta">
|
<div className="document-card__meta">
|
||||||
<div
|
<div
|
||||||
@@ -504,7 +553,6 @@ const DocumentsTable = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -621,13 +669,20 @@ const DocumentsTable = ({
|
|||||||
<td className="doc-list__name">
|
<td className="doc-list__name">
|
||||||
<div className="doc-list__name-content">
|
<div className="doc-list__name-content">
|
||||||
<span>{folder.name}</span>
|
<span>{folder.name}</span>
|
||||||
{folder.id !== 'root' && (
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>Folder</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td className="actions">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{folder.id !== 'root' && onFolderRename && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button ghost doc-list__icon-button"
|
className="icon-button ghost"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename folder ${folder.name}`}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!onFolderRename) return;
|
|
||||||
const nextName = window.prompt('Rename folder', folder.name || '');
|
const nextName = window.prompt('Rename folder', folder.name || '');
|
||||||
if (!nextName) {
|
if (!nextName) {
|
||||||
return;
|
return;
|
||||||
@@ -638,28 +693,24 @@ const DocumentsTable = ({
|
|||||||
}
|
}
|
||||||
onFolderRename(folder.id, trimmed);
|
onFolderRename(folder.id, trimmed);
|
||||||
}}
|
}}
|
||||||
title="Rename folder"
|
|
||||||
aria-label={`Rename folder ${folder.name}`}
|
|
||||||
>
|
>
|
||||||
<EditIcon className="doc-list__icon" size={16} />
|
<EditIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label={`Delete folder ${folder.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onFolderDelete(folder.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>Folder</td>
|
|
||||||
<td>—</td>
|
|
||||||
<td className="actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button danger"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onFolderDelete(folder.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -692,36 +743,13 @@ const DocumentsTable = ({
|
|||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="doc-list__name">
|
<td className="doc-list__name">
|
||||||
<div className="doc-name">
|
<div className="doc-name">
|
||||||
<div className="doc-list__name-content">
|
<div className="doc-list__name-content">
|
||||||
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
<span className="doc-name__title">{doc.title || doc.original_name}</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button ghost doc-list__icon-button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (!onDocumentRename) return;
|
|
||||||
const nextName = window.prompt(
|
|
||||||
'Rename document',
|
|
||||||
doc.title || doc.original_name || '',
|
|
||||||
);
|
|
||||||
if (!nextName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trimmed = nextName.trim();
|
|
||||||
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDocumentRename(doc.id, trimmed);
|
|
||||||
}}
|
|
||||||
title="Rename document"
|
|
||||||
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
|
||||||
>
|
|
||||||
<EditIcon className="doc-list__icon" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{(doc.tags || []).length > 0 && (
|
{(doc.tags || []).length > 0 && (
|
||||||
<div className="doc-name__tags">
|
<div className="doc-name__tags">
|
||||||
@@ -741,7 +769,6 @@ const DocumentsTable = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -791,31 +818,59 @@ const DocumentsTable = ({
|
|||||||
</td>
|
</td>
|
||||||
<td className="actions">
|
<td className="actions">
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
|
{onDocumentRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename document ${doc.title || doc.original_name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const nextName = window.prompt(
|
||||||
|
'Rename document',
|
||||||
|
doc.title || doc.original_name || '',
|
||||||
|
);
|
||||||
|
if (!nextName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || trimmed === (doc.title || doc.original_name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDocumentRename(doc.id, trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{downloadHref ? (
|
{downloadHref ? (
|
||||||
<a
|
<a
|
||||||
className="button-link with-icon"
|
className="icon-button"
|
||||||
href={downloadHref}
|
href={downloadHref}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
title="Download"
|
||||||
|
aria-label="Download document"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onAuxClick={(event) => event.stopPropagation()}
|
onAuxClick={(event) => event.stopPropagation()}
|
||||||
onContextMenu={(event) => event.stopPropagation()}
|
onContextMenu={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="icon-inline" />
|
<DownloadIcon className="icon-inline" />
|
||||||
<span>Download</span>
|
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="meta">No download</span>
|
<span className="meta">No download</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="danger"
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete document"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onDocumentDelete?.(doc.id);
|
onDocumentDelete?.(doc.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
<TrashIcon className="icon-inline" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+931
-236
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons';
|
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon, ChevronsLeftIcon } from '../ui/icons';
|
||||||
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
|
||||||
@@ -136,6 +136,19 @@ const Sidebar = ({
|
|||||||
correspondents = [],
|
correspondents = [],
|
||||||
activeCorrespondentIds = [],
|
activeCorrespondentIds = [],
|
||||||
onToggleCorrespondentFilter,
|
onToggleCorrespondentFilter,
|
||||||
|
onManageTags,
|
||||||
|
onManageCorrespondents,
|
||||||
|
searchQuery = '',
|
||||||
|
onSearchChange,
|
||||||
|
onSearchSubmit,
|
||||||
|
onSearchClear,
|
||||||
|
isFilterActive,
|
||||||
|
appStatus,
|
||||||
|
loading,
|
||||||
|
previewActive,
|
||||||
|
onLogout,
|
||||||
|
status,
|
||||||
|
onCollapse,
|
||||||
}) => {
|
}) => {
|
||||||
const sortedCorrespondents = useMemo(
|
const sortedCorrespondents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -150,6 +163,27 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||||
const activeTagSet = new Set(activeTagIds);
|
const activeTagSet = new Set(activeTagIds);
|
||||||
|
const handleManageTags = onManageTags || (() => {});
|
||||||
|
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
||||||
|
const handleSearchInputChange = useCallback(
|
||||||
|
(event) => {
|
||||||
|
onSearchChange?.(event.target.value);
|
||||||
|
},
|
||||||
|
[onSearchChange],
|
||||||
|
);
|
||||||
|
const handleSearchFormSubmit = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSearchSubmit?.();
|
||||||
|
},
|
||||||
|
[onSearchSubmit],
|
||||||
|
);
|
||||||
|
const handleSearchClear = useCallback(() => {
|
||||||
|
onSearchClear?.();
|
||||||
|
}, [onSearchClear]);
|
||||||
|
const handleLogoutClick = useCallback(() => {
|
||||||
|
onLogout?.();
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
const renderNodes = useCallback(
|
const renderNodes = useCallback(
|
||||||
(ids, depth) =>
|
(ids, depth) =>
|
||||||
@@ -193,30 +227,80 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rootNode = folderNodes.get('root');
|
const rootNode = folderNodes.get('root');
|
||||||
|
const hintText = appStatus === 'bootstrapping' && loading
|
||||||
|
? 'Loading your library…'
|
||||||
|
: previewActive
|
||||||
|
? 'Viewing document preview. Press ← Back to return to the library.'
|
||||||
|
: 'Drag files here to upload.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar column">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-section sidebar-section--folders">
|
<div className="panel-header sidebar__header">
|
||||||
<div className="sidebar-section__header">
|
<div className="panel-actions">
|
||||||
<h3>Folders</h3>
|
<h1 className="sidebar__title">Papercrate</h1>
|
||||||
|
<div className="spacer" />
|
||||||
|
{onCollapse ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button ghost"
|
||||||
|
onClick={onCollapse}
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
title="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<ChevronsLeftIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<ul className="folder-tree">
|
|
||||||
{rootNode && renderNodes([rootNode.id], 0)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-section">
|
<div className="panel-body sidebar__body">
|
||||||
<div className="sidebar-section__header">
|
<span className="sidebar__hint">{hintText}</span>
|
||||||
<h3>Tags</h3>
|
{status && (
|
||||||
<span className="meta">{tags.length}</span>
|
<div className="sidebar__status">
|
||||||
|
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onSearchChange && (
|
||||||
|
<form className="sidebar__search" onSubmit={handleSearchFormSubmit}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchInputChange}
|
||||||
|
placeholder="Search documents"
|
||||||
|
aria-label="Search documents"
|
||||||
|
/>
|
||||||
|
{isFilterActive && (
|
||||||
|
<button type="button" onClick={handleSearchClear}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<div className="sidebar-section sidebar-section--folders">
|
||||||
|
<div className="sidebar-section__header">
|
||||||
|
<h3>Folders</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="folder-tree">
|
||||||
|
{rootNode && renderNodes([rootNode.id], 0)}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="sidebar-section">
|
||||||
className={`sidebar-tag-cloud${
|
<div className="sidebar-section__header">
|
||||||
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
<button
|
||||||
}`}
|
type="button"
|
||||||
role="list"
|
className="sidebar-section__title"
|
||||||
>
|
onClick={handleManageTags}
|
||||||
{tags.length ? (
|
>
|
||||||
tags.map((tag) => {
|
<h3>Tags</h3>
|
||||||
|
<span className="meta">{tags.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`sidebar-tag-cloud${
|
||||||
|
activeTagSet.size ? ' sidebar-tag-cloud--has-active' : ''
|
||||||
|
}`}
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
{tags.map((tag) => {
|
||||||
const isActive = activeTagSet.has(tag.id);
|
const isActive = activeTagSet.has(tag.id);
|
||||||
const style = getTagColorStyle(tag.color);
|
const style = getTagColorStyle(tag.color);
|
||||||
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||||
@@ -249,20 +333,22 @@ const Sidebar = ({
|
|||||||
{tag.label}
|
{tag.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
) : (
|
</div>
|
||||||
<span className="meta">No tags yet</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section__header">
|
||||||
<div className="sidebar-section__header">
|
<button
|
||||||
<h3>Correspondents</h3>
|
type="button"
|
||||||
<span className="meta">{correspondents.length}</span>
|
className="sidebar-section__title"
|
||||||
</div>
|
onClick={handleManageCorrespondents}
|
||||||
<ul className="sidebar-correspondent-list">
|
>
|
||||||
{sortedCorrespondents.length ? (
|
<h3>Correspondents</h3>
|
||||||
sortedCorrespondents.map((correspondent) => {
|
<span className="meta">{correspondents.length}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="sidebar-correspondent-list">
|
||||||
|
{sortedCorrespondents.map((correspondent) => {
|
||||||
const isActive = activeCorrespondentSet.has(correspondent.id);
|
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||||
const label = correspondent.name || 'Unnamed';
|
const label = correspondent.name || 'Unnamed';
|
||||||
@@ -275,7 +361,6 @@ const Sidebar = ({
|
|||||||
<span
|
<span
|
||||||
className={className}
|
className={className}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleSelect}
|
onClick={handleSelect}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -288,11 +373,14 @@ const Sidebar = ({
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
) : (
|
</ul>
|
||||||
<li className="meta">No correspondents yet</li>
|
</div>
|
||||||
)}
|
<div className="sidebar__footer">
|
||||||
</ul>
|
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,57 +13,6 @@
|
|||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 1rem 0.5rem;
|
|
||||||
background-color: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header__meta h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-breadcrumbs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-crumb {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-crumb.is-current {
|
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-crumb-separator {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-header__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-canvas {
|
.skeuo-canvas {
|
||||||
@@ -106,7 +55,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: width 0.28s ease, height 0.28s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item:focus-visible {
|
.skeuo-item:focus-visible {
|
||||||
@@ -119,10 +67,6 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item.is-zoomed {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||||
outline: 1em dashed var(--accent);
|
outline: 1em dashed var(--accent);
|
||||||
outline-offset: 1.41em;
|
outline-offset: 1.41em;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user