Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a3a53314 | ||
|
|
4a06fa82eb | ||
|
|
80238bb7a1 | ||
|
|
dcbd46531e | ||
|
|
0864b39336 | ||
|
|
2a0c96bb4c | ||
|
|
c53213a357 | ||
|
|
dc633783e0 | ||
|
|
e33ab71fac | ||
|
|
f7a3e3f0f8 | ||
|
|
72df3dec3b | ||
|
|
b80ec5c6ac | ||
|
|
fbd3aff6d8 | ||
|
|
0ad79c9bc1 | ||
|
|
e175d28c2c | ||
|
|
8e3f09774a | ||
|
|
dbc54032f7 | ||
|
|
047b99e2aa | ||
|
|
1859078cf4 | ||
|
|
c44ba91ec7 | ||
|
|
96a6d0ee5d | ||
|
|
5619bb27a1 | ||
|
|
dfc99c7d15 | ||
|
|
fb82f505b6 | ||
|
|
5970340a17 | ||
|
|
9a77e76ff4 | ||
|
|
3591415d46 | ||
|
|
ab06205744 | ||
|
|
0689e680fa | ||
|
|
297d5aca1f | ||
|
|
0a023c1556 | ||
|
|
aa600af7b2 | ||
|
|
fc4aae8c0c | ||
|
|
24c0b5fa52 | ||
|
|
2af2af3460 |
@@ -1,5 +1,15 @@
|
||||
# Papercrate
|
||||
|
||||
## Local Development
|
||||
|
||||
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
|
||||
|
||||
```bash
|
||||
tmux -f papercrate.tmux attach
|
||||
```
|
||||
|
||||
This creates windows for the compose stack, frontend dev server, backend API, and background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command.
|
||||
|
||||
## Backend Integration Tests
|
||||
|
||||
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
|
||||
|
||||
@@ -44,6 +44,7 @@ base64 = "0.21"
|
||||
quick-xml = "0.32"
|
||||
futures-util = "0.3"
|
||||
url = "2.5"
|
||||
once_cell = "1.19"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ COPY migrations ./migrations
|
||||
COPY tests ./tests
|
||||
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
|
||||
|
||||
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/worker /usr/local/bin/papercrate-worker
|
||||
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 migrations ./migrations
|
||||
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;
|
||||
@@ -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);
|
||||
+46
-4
@@ -15,6 +15,8 @@ pub struct JwtService {
|
||||
expiry: Duration,
|
||||
download_audience: String,
|
||||
download_expiry: Duration,
|
||||
selector_audience: String,
|
||||
selector_expiry: Duration,
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
@@ -27,16 +29,18 @@ impl JwtService {
|
||||
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
||||
download_audience: config.download_token_audience.clone(),
|
||||
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 exp = now + self.expiry;
|
||||
let claims = Claims {
|
||||
sub: user_id,
|
||||
tenant_id,
|
||||
username: username.to_owned(),
|
||||
role: role.to_owned(),
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
@@ -54,12 +58,18 @@ impl JwtService {
|
||||
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 exp = now + self.download_expiry;
|
||||
let claims = DownloadClaims {
|
||||
doc_id: document_id,
|
||||
user_id,
|
||||
tenant_id,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.download_audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
@@ -76,13 +86,35 @@ impl JwtService {
|
||||
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
|
||||
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)]
|
||||
pub struct Claims {
|
||||
pub sub: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
@@ -93,6 +125,16 @@ pub struct Claims {
|
||||
pub struct DownloadClaims {
|
||||
pub doc_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 aud: String,
|
||||
pub iat: usize,
|
||||
|
||||
+50
-4
@@ -6,13 +6,17 @@ use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||
use axum_extra::TypedHeader;
|
||||
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)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub tenant_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -23,6 +27,10 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||
return Ok(user.clone());
|
||||
}
|
||||
|
||||
let TypedHeader(Authorization(bearer)) =
|
||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
|
||||
.await
|
||||
@@ -33,10 +41,48 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
|
||||
.verify_token(bearer.token())
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
Ok(AuthenticatedUser {
|
||||
let user = AuthenticatedUser {
|
||||
user_id: claims.sub,
|
||||
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,75 +0,0 @@
|
||||
use std::env;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use backend::{
|
||||
config::AppConfig,
|
||||
db,
|
||||
models::DocumentAsset,
|
||||
s3,
|
||||
schema::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());
|
||||
|
||||
for asset in &assets {
|
||||
if let Err(err) = storage.delete_object(&asset.s3_key).await {
|
||||
eprintln!(
|
||||
"Failed to delete object {} from storage: {err}",
|
||||
asset.s3_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
diesel::delete(document_assets::table)
|
||||
.execute(&mut conn)
|
||||
.context("failed to remove asset records")?;
|
||||
|
||||
println!("Asset records deleted.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,47 +1,24 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db;
|
||||
use backend::routes::webdav;
|
||||
use backend::s3::build_client;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::S3Storage;
|
||||
use backend::{routes::webdav, utils::bootstrap::init_component};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
let state = init_component("webdav", None).await?;
|
||||
let webdav_host = state.config.webdav_host.clone();
|
||||
let webdav_port = state.config.webdav_port;
|
||||
tracing::info!(
|
||||
component = "webdav",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
server_host = %config.server_host,
|
||||
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"
|
||||
webdav_host = %webdav_host,
|
||||
webdav_port,
|
||||
"starting webdav server"
|
||||
);
|
||||
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 = {
|
||||
let config = state.config.clone();
|
||||
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
|
||||
};
|
||||
let router = webdav::create_router().with_state(state);
|
||||
let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?;
|
||||
let router = webdav::create_router().with_state(state.as_ref().clone());
|
||||
|
||||
let listener = TcpListener::bind(listen_addr).await?;
|
||||
tracing::info!("listening for WebDAV on {}", listen_addr);
|
||||
@@ -49,12 +26,3 @@ async fn main() -> anyhow::Result<()> {
|
||||
axum::serve(listener, Shared::new(router)).await?;
|
||||
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 tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::{
|
||||
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
|
||||
state::AppState, storage::S3Storage, Worker,
|
||||
};
|
||||
use backend::{default_handlers, utils::bootstrap::init_component, Worker};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
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 state = init_component("worker", Some(1)).await?;
|
||||
tracing::info!(component = "worker", "starting worker process");
|
||||
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
|
||||
|
||||
tokio::select! {
|
||||
@@ -39,12 +19,3 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -30,9 +30,24 @@ pub struct AppConfig {
|
||||
pub s3_bucket: String,
|
||||
pub quickwit_endpoint: Option<String>,
|
||||
pub quickwit_index: Option<String>,
|
||||
pub default_tenant_slug: String,
|
||||
}
|
||||
|
||||
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> {
|
||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
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 quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").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 {
|
||||
database_url,
|
||||
@@ -104,6 +121,7 @@ impl AppConfig {
|
||||
s3_bucket,
|
||||
quickwit_endpoint,
|
||||
quickwit_index,
|
||||
default_tenant_slug,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ pub type JobQueueResult<T> = Result<T, JobQueueError>;
|
||||
|
||||
pub fn enqueue_job(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
job_type: &str,
|
||||
payload: Value,
|
||||
run_after: Option<NaiveDateTime>,
|
||||
@@ -40,6 +41,7 @@ pub fn enqueue_job(
|
||||
payload,
|
||||
status: STATUS_QUEUED.to_string(),
|
||||
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(jobs::table)
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod s3;
|
||||
pub mod schema;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod tenants;
|
||||
pub mod utils;
|
||||
pub mod workers;
|
||||
pub use workers::{default_handlers, Worker};
|
||||
|
||||
+9
-38
@@ -1,57 +1,28 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tower::make::Shared;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db;
|
||||
use backend::routes;
|
||||
use backend::s3::build_client;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::S3Storage;
|
||||
use backend::{routes, utils::bootstrap::init_component};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = AppConfig::from_env()?;
|
||||
let state = init_component("api", None).await?;
|
||||
let server_host = state.config.server_host.clone();
|
||||
let server_port = state.config.server_port;
|
||||
tracing::info!(
|
||||
component = "api",
|
||||
database_url = %config.redacted_database_url(),
|
||||
pool_size = config.database_max_pool_size,
|
||||
server_host = %config.server_host,
|
||||
server_port = config.server_port,
|
||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||
s3_bucket = %config.s3_bucket,
|
||||
"loaded backend configuration"
|
||||
server_host = %server_host,
|
||||
server_port,
|
||||
"starting api server"
|
||||
);
|
||||
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!("{}:{}", state.config.server_host, state.config.server_port).parse()?;
|
||||
let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
tracing::info!("listening on {}", addr);
|
||||
|
||||
axum::serve(listener, Shared::new(router)).await?;
|
||||
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();
|
||||
}
|
||||
|
||||
+81
-4
@@ -4,13 +4,48 @@ use uuid::Uuid;
|
||||
|
||||
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)]
|
||||
#[diesel(table_name = users)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
@@ -21,7 +56,6 @@ pub struct NewUser {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -32,6 +66,7 @@ pub struct Folder {
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -40,6 +75,7 @@ pub struct NewFolder {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -58,6 +94,7 @@ pub struct Document {
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub current_version_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -72,6 +109,7 @@ pub struct NewDocument {
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -87,6 +125,7 @@ pub struct DocumentVersion {
|
||||
pub created_at: NaiveDateTime,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -100,6 +139,7 @@ pub struct NewDocumentVersion {
|
||||
pub checksum: String,
|
||||
pub operations_summary: serde_json::Value,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -109,10 +149,11 @@ pub struct DocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub cardinality: Option<i32>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -121,9 +162,33 @@ pub struct NewDocumentAsset {
|
||||
pub id: Uuid,
|
||||
pub document_version_id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub cardinality: Option<i32>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = document_asset_objects)]
|
||||
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
|
||||
pub struct DocumentAssetObject {
|
||||
pub id: Uuid,
|
||||
pub asset_id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub s3_key: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = document_asset_objects)]
|
||||
pub struct NewDocumentAssetObject {
|
||||
pub id: Uuid,
|
||||
pub asset_id: Uuid,
|
||||
pub ordinal: i32,
|
||||
pub s3_key: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -138,6 +203,7 @@ pub struct Job {
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -148,6 +214,7 @@ pub struct NewJob {
|
||||
pub payload: serde_json::Value,
|
||||
pub status: String,
|
||||
pub run_after: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -157,6 +224,7 @@ pub struct Tag {
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -165,6 +233,7 @@ pub struct NewTag {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -178,6 +247,7 @@ pub struct DocumentTag {
|
||||
pub tag_id: Uuid,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -186,6 +256,7 @@ pub struct NewDocumentTag {
|
||||
pub document_id: Uuid,
|
||||
pub tag_id: Uuid,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -196,6 +267,7 @@ pub struct Correspondent {
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -204,6 +276,7 @@ pub struct NewCorrespondent {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Associations)]
|
||||
@@ -217,6 +290,7 @@ pub struct DocumentCorrespondent {
|
||||
pub role: String,
|
||||
pub assigned_at: NaiveDateTime,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -226,6 +300,7 @@ pub struct NewDocumentCorrespondent {
|
||||
pub correspondent_id: Uuid,
|
||||
pub role: String,
|
||||
pub assigned_by: Option<Uuid>,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
@@ -240,6 +315,7 @@ pub struct RefreshToken {
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
@@ -250,4 +326,5 @@ pub struct NewRefreshToken {
|
||||
pub token_hash: String,
|
||||
pub issued_at: NaiveDateTime,
|
||||
pub expires_at: NaiveDateTime,
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
+152
-72
@@ -1,11 +1,15 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
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 diesel::prelude::*;
|
||||
use diesel::{pg::PgConnection, prelude::*};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -14,8 +18,11 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
auth::{password, AuthenticatedUser},
|
||||
error::{AppError, AppResult},
|
||||
models::{NewRefreshToken, RefreshToken, User},
|
||||
schema::{refresh_tokens, users::dsl},
|
||||
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership},
|
||||
schema::{
|
||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
||||
users::dsl,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -27,6 +34,8 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub preferred_tenant_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -36,11 +45,28 @@ pub struct LoginResponse {
|
||||
pub expires_in: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSummary {
|
||||
pub tenant_id: Uuid,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub selection_token: String,
|
||||
pub tenants: Vec<TenantSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||
let mut conn = state.db()?;
|
||||
) -> AppResult<Response> {
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(&payload.username))
|
||||
@@ -53,55 +79,67 @@ pub async fn login(
|
||||
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
|
||||
.generate_token(user.id, &user.username, &user.role)
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let now = Utc::now();
|
||||
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 tenants = memberships
|
||||
.into_iter()
|
||||
.map(|(_, tenant)| TenantSummary {
|
||||
tenant_id: tenant.id,
|
||||
slug: tenant.slug,
|
||||
})
|
||||
.collect();
|
||||
|
||||
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(),
|
||||
};
|
||||
let response = Json(TenantSelectionResponse {
|
||||
selection_token,
|
||||
tenants,
|
||||
})
|
||||
.into_response();
|
||||
|
||||
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, &refresh_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok((
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
}),
|
||||
))
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
|
||||
) -> AppResult<Response> {
|
||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||
let refresh_value = cookies
|
||||
.get(REFRESH_COOKIE_NAME)
|
||||
.ok_or_else(AppError::unauthorized)?;
|
||||
|
||||
let hashed = hash_refresh_token(refresh_value);
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
@@ -128,41 +166,39 @@ pub async fn refresh(
|
||||
.first(&mut conn)
|
||||
.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
|
||||
.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)?;
|
||||
|
||||
let new_refresh_value = generate_refresh_token();
|
||||
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,
|
||||
}),
|
||||
))
|
||||
issue_session(&state, &mut conn, &user, payload.tenant_id)
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
@@ -170,7 +206,7 @@ pub async fn logout(
|
||||
user: AuthenticatedUser,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut rows_affected = 0;
|
||||
|
||||
@@ -214,6 +250,50 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||
Json(user)
|
||||
}
|
||||
|
||||
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 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,
|
||||
})
|
||||
.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 {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::Utc;
|
||||
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -13,14 +8,16 @@ use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
models::{Correspondent, NewCorrespondent},
|
||||
schema::{correspondents, document_correspondents},
|
||||
state::AppState,
|
||||
utils::{
|
||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||
time::to_iso,
|
||||
},
|
||||
};
|
||||
|
||||
use super::documents::to_iso;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CorrespondentUsage {
|
||||
pub total: i64,
|
||||
@@ -59,15 +56,19 @@ struct CorrespondentChangeset<'a> {
|
||||
}
|
||||
|
||||
pub async fn list_correspondents(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let correspondents_list: Vec<Correspondent> = correspondents::table
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.order(correspondents::name.asc())
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by((
|
||||
document_correspondents::correspondent_id,
|
||||
document_correspondents::role,
|
||||
@@ -93,11 +94,15 @@ pub async fn list_correspondents(
|
||||
response.push(build_summary(correspondent, role_counts));
|
||||
}
|
||||
|
||||
Ok(Json(response))
|
||||
response.into_json()
|
||||
}
|
||||
|
||||
pub async fn create_correspondent(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let name = payload.name.trim();
|
||||
@@ -111,9 +116,9 @@ pub async fn create_correspondent(
|
||||
id: new_id,
|
||||
name: name.to_string(),
|
||||
metadata: metadata_value,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let mut conn = state.db()?;
|
||||
match diesel::insert_into(correspondents::table)
|
||||
.values(&new_correspondent)
|
||||
.execute(&mut conn)
|
||||
@@ -125,19 +130,29 @@ pub async fn create_correspondent(
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?;
|
||||
Ok(Json(build_summary(correspondent, BTreeMap::new())))
|
||||
let correspondent: Correspondent = correspondents::table
|
||||
.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(
|
||||
State(state): State<AppState>,
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let mut conn = state.db()?;
|
||||
let existing: Correspondent = correspondents::table
|
||||
.find(correspondent_id)
|
||||
.first(&mut conn)?;
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
let mut new_name: Option<String> = None;
|
||||
if let Some(ref candidate) = payload.name {
|
||||
@@ -149,6 +164,7 @@ pub async fn update_correspondent(
|
||||
let duplicate = correspondents::table
|
||||
.filter(correspondents::name.eq(trimmed))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
@@ -167,8 +183,8 @@ pub async fn update_correspondent(
|
||||
}
|
||||
|
||||
if new_name.is_none() && new_metadata.is_none() {
|
||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
||||
return Ok(Json(build_summary(existing.clone(), usage)));
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
return build_summary(existing.clone(), usage).into_json();
|
||||
}
|
||||
|
||||
let mut changeset = CorrespondentChangeset::default();
|
||||
@@ -180,24 +196,33 @@ pub async fn update_correspondent(
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
diesel::update(correspondents::table.find(correspondent_id))
|
||||
.set((&changeset, correspondents::updated_at.eq(now)))
|
||||
.execute(&mut conn)?;
|
||||
diesel::update(
|
||||
correspondents::table
|
||||
.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
|
||||
.find(correspondent_id)
|
||||
.first(&mut conn)?;
|
||||
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
|
||||
Ok(Json(build_summary(updated, usage)))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
|
||||
build_summary(updated, usage).into_json()
|
||||
}
|
||||
|
||||
pub async fn delete_correspondent(
|
||||
State(state): State<AppState>,
|
||||
Path(correspondent_id): Path<Uuid>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let usage: i64 = document_correspondents::table
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
@@ -208,12 +233,16 @@ pub async fn delete_correspondent(
|
||||
));
|
||||
}
|
||||
|
||||
let deleted =
|
||||
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
|
||||
let deleted = diesel::delete(
|
||||
correspondents::table
|
||||
.filter(correspondents::id.eq(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)?;
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn build_summary(
|
||||
@@ -243,10 +272,12 @@ fn normalize_metadata(input: Option<Value>) -> Value {
|
||||
|
||||
fn load_usage_for_correspondent(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<BTreeMap<String, i64>> {
|
||||
let rows: Vec<(String, i64)> = document_correspondents::table
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.group_by(document_correspondents::role)
|
||||
.select((document_correspondents::role, count_star()))
|
||||
.load(conn)?;
|
||||
|
||||
+474
-263
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,15 @@ use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{documents, folders};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth::AuthenticatedUser,
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::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::time::to_iso;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateFolderRequest {
|
||||
@@ -70,15 +71,17 @@ pub struct FolderInfo {
|
||||
}
|
||||
|
||||
pub async fn ensure_folder_path(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<EnsureFolderPathRequest>,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
if payload.segments.is_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 mut current_parent = payload.parent_id;
|
||||
let mut last_folder: Option<Folder> = None;
|
||||
@@ -93,12 +96,14 @@ pub async fn ensure_folder_path(
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
@@ -110,6 +115,7 @@ pub async fn ensure_folder_path(
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: current_parent,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(folders::table)
|
||||
@@ -132,19 +138,22 @@ pub async fn ensure_folder_path(
|
||||
}
|
||||
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> AppResult<Json<FolderResponse>> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: payload.name.trim().to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(folders::table)
|
||||
@@ -161,10 +170,13 @@ pub async fn list_folder_contents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
Query(query): Query<FolderContentsQuery>,
|
||||
user: AuthenticatedUser,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<FolderContentsResponse>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
||||
None
|
||||
} else {
|
||||
@@ -176,7 +188,10 @@ pub async fn list_folder_contents(
|
||||
|
||||
let folder = match folder_id {
|
||||
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,
|
||||
};
|
||||
@@ -184,11 +199,13 @@ pub async fn list_folder_contents(
|
||||
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(parent_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(folders::name.asc())
|
||||
.load(&mut conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(folders::name.asc())
|
||||
.load(&mut conn)?
|
||||
};
|
||||
@@ -197,6 +214,7 @@ pub async fn list_folder_contents(
|
||||
let documents = if query.include_documents {
|
||||
let docs_query = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.order(documents::uploaded_at.desc());
|
||||
|
||||
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
||||
@@ -214,7 +232,7 @@ pub async fn list_folder_contents(
|
||||
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
||||
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());
|
||||
for doc in docs {
|
||||
@@ -223,7 +241,7 @@ pub async fn list_folder_contents(
|
||||
let current_version = primary_versions.get(&doc.id).cloned();
|
||||
documents.push(to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
user_id,
|
||||
doc,
|
||||
tags,
|
||||
correspondents,
|
||||
@@ -244,16 +262,23 @@ pub async fn list_folder_contents(
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
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(
|
||||
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)?;
|
||||
|
||||
@@ -266,6 +291,7 @@ pub async fn delete_folder(
|
||||
let has_documents: bool = diesel::select(exists(
|
||||
documents::table
|
||||
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.filter(documents::deleted_at.is_null()),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
@@ -276,7 +302,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(())
|
||||
})?;
|
||||
@@ -285,14 +316,19 @@ pub async fn delete_folder(
|
||||
}
|
||||
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
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 parent_changed = false;
|
||||
@@ -303,9 +339,12 @@ pub async fn update_folder(
|
||||
}
|
||||
|
||||
if let Some(parent_id) = parent_request {
|
||||
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
|
||||
let _parent: Folder = folders::table
|
||||
.find(parent_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, folder_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",
|
||||
@@ -341,6 +380,7 @@ pub async fn update_folder(
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
@@ -348,6 +388,7 @@ pub async fn update_folder(
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
};
|
||||
@@ -358,12 +399,16 @@ pub async fn update_folder(
|
||||
));
|
||||
}
|
||||
|
||||
diesel::update(folders::table.find(folder_id))
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
))
|
||||
.execute(conn)?;
|
||||
diesel::update(
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -383,6 +428,7 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
|
||||
|
||||
pub(super) fn gather_descendant_folder_ids(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut ids = vec![folder_id];
|
||||
@@ -391,6 +437,7 @@ pub(super) fn gather_descendant_folder_ids(
|
||||
while let Some(current) = queue.pop() {
|
||||
let child_ids: Vec<Uuid> = folders::table
|
||||
.filter(folders::parent_id.eq(Some(current)))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.select(folders::id)
|
||||
.load(conn)?;
|
||||
queue.extend(child_ids.iter().copied());
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/login", post(auth::login))
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
.route("/me", get(auth::me));
|
||||
|
||||
let documents_routes = Router::new()
|
||||
@@ -57,7 +58,6 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
"/",
|
||||
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/tags", post(documents::bulk_update_tags))
|
||||
.route(
|
||||
@@ -75,7 +75,6 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.patch(documents::update_document),
|
||||
)
|
||||
.route("/:id/download", get(documents::download_document))
|
||||
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
|
||||
.route(
|
||||
"/:id/assets",
|
||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||
@@ -120,11 +119,14 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
);
|
||||
|
||||
let protected_state = state.clone();
|
||||
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||
|
||||
let protected_routes = Router::new()
|
||||
.nest("/api/documents", documents_routes)
|
||||
.nest("/api/folders", folders_routes)
|
||||
.nest("/api/tags", tags_routes)
|
||||
.nest("/api/correspondents", correspondents_routes)
|
||||
.nest("/api/assets", assets_routes)
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
Router::new()
|
||||
|
||||
+77
-34
@@ -1,19 +1,16 @@
|
||||
use crate::utils::json::{classify_nullable, NullableValue};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::TenantScopedConn;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{NewTag, Tag};
|
||||
use crate::schema::{document_tags, tags};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTagRequest {
|
||||
@@ -36,19 +33,27 @@ pub struct TagCatalogEntry {
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
|
||||
pub async fn list_tags(
|
||||
TenantScopedConn {
|
||||
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
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.group_by(document_tags::tag_id)
|
||||
.select((document_tags::tag_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||
|
||||
let response = tag_list
|
||||
let response: Vec<TagCatalogEntry> = tag_list
|
||||
.into_iter()
|
||||
.map(|tag| TagCatalogEntry {
|
||||
id: tag.id,
|
||||
@@ -58,22 +63,26 @@ pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagC
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(response))
|
||||
response.into_json()
|
||||
}
|
||||
|
||||
pub async fn create_tag(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
if payload.label.trim().is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
let new_tag = NewTag {
|
||||
id: Uuid::new_v4(),
|
||||
label: payload.label.trim().to_string(),
|
||||
color: payload.color,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
match diesel::insert_into(tags::table)
|
||||
@@ -90,22 +99,35 @@ pub async fn create_tag(
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
}
|
||||
|
||||
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
|
||||
Ok(Json(TagCatalogEntry {
|
||||
let tag: Tag = tags::table
|
||||
.find(new_tag.id)
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.one()?;
|
||||
|
||||
TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: 0,
|
||||
}))
|
||||
}
|
||||
.into_json()
|
||||
}
|
||||
|
||||
pub async fn update_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
let mut conn = state.db()?;
|
||||
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
||||
let existing: Tag = tags::table
|
||||
.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 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))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return Ok(Json(TagCatalogEntry {
|
||||
return TagCatalogEntry {
|
||||
id: existing.id,
|
||||
label: existing.label.clone(),
|
||||
color: existing.color.clone(),
|
||||
usage_count,
|
||||
}));
|
||||
}
|
||||
.into_json();
|
||||
}
|
||||
|
||||
let mut new_label: Option<String> = None;
|
||||
@@ -140,6 +163,7 @@ pub async fn update_tag(
|
||||
let duplicate = tags::table
|
||||
.filter(tags::label.eq(trimmed))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
@@ -174,6 +198,7 @@ pub async fn update_tag(
|
||||
if !label_changed && !color_changed {
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
return Ok(Json(TagCatalogEntry {
|
||||
@@ -191,32 +216,45 @@ pub async fn update_tag(
|
||||
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
||||
};
|
||||
|
||||
diesel::update(tags::table.find(tag_id))
|
||||
.set(&changeset)
|
||||
.execute(&mut conn)?;
|
||||
diesel::update(
|
||||
tags::table
|
||||
.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
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
Ok(Json(TagCatalogEntry {
|
||||
TagCatalogEntry {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
color: updated.color,
|
||||
usage_count,
|
||||
}))
|
||||
}
|
||||
.into_json()
|
||||
}
|
||||
|
||||
pub async fn delete_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
) -> AppResult<impl axum::response::IntoResponse> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
let usage: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.select(count_star())
|
||||
.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 {
|
||||
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::schema::{
|
||||
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::utils::{http::inline_content_disposition, time::to_http_date};
|
||||
|
||||
const REALM: &str = "Papercrate WebDAV";
|
||||
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WebDavUser {
|
||||
struct TenantEntry {
|
||||
tenant_id: Uuid,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WebDavContext {
|
||||
_user_id: Uuid,
|
||||
_username: String,
|
||||
tenants: Vec<TenantEntry>,
|
||||
}
|
||||
|
||||
pub fn create_router() -> Router<AppState> {
|
||||
@@ -68,7 +77,7 @@ async fn handle_propfind(
|
||||
path: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let _user = match authenticate(state, &headers)? {
|
||||
let context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
@@ -79,25 +88,44 @@ async fn handle_propfind(
|
||||
};
|
||||
|
||||
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 {
|
||||
ResolvedPath::Root => {
|
||||
let contents = fetch_folder_contents(state, None)?;
|
||||
build_resources_for_folder(None, &[], &contents, depth)
|
||||
let resources = if segments.is_empty() {
|
||||
build_account_root_resources(&context.tenants, depth)
|
||||
} else {
|
||||
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)
|
||||
@@ -118,13 +146,34 @@ async fn handle_get_or_head(
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
) -> Result<Response, AppError> {
|
||||
let _user = match authenticate(state, &headers)? {
|
||||
let context = match authenticate(state, &headers)? {
|
||||
Some(user) => user,
|
||||
None => return Ok(unauthorized_response()),
|
||||
};
|
||||
|
||||
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,
|
||||
None => return Ok(not_found_response()),
|
||||
};
|
||||
@@ -219,21 +268,29 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
|
||||
|
||||
fn fetch_folder_contents(
|
||||
state: &AppState,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
) -> AppResult<WebDavFolderContents> {
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_for_tenant(tenant_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,
|
||||
};
|
||||
|
||||
let subfolders: Vec<Folder> = match folder_id {
|
||||
Some(id) => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.eq(Some(id)))
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(&mut conn)?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.order(folders_dsl::name.asc())
|
||||
.load(&mut conn)?,
|
||||
@@ -241,6 +298,7 @@ fn fetch_folder_contents(
|
||||
|
||||
let mut docs_query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
docs_query = match folder_id {
|
||||
@@ -290,8 +348,9 @@ async fn stream_document(
|
||||
) -> Result<Response, AppError> {
|
||||
let range_header = headers.get(header::RANGE).cloned();
|
||||
|
||||
let url = state
|
||||
.storage
|
||||
let storage = state.storage_for_tenant(document.tenant_id)?;
|
||||
|
||||
let url = storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||
@@ -338,7 +397,7 @@ async fn stream_document(
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -360,7 +419,7 @@ async fn stream_document(
|
||||
.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");
|
||||
let authorization = match headers.get(header::AUTHORIZATION) {
|
||||
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");
|
||||
let mut conn = state.db()?;
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let user: User = match users_dsl::users
|
||||
.filter(users_dsl::username.eq(username))
|
||||
@@ -427,10 +486,27 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUs
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
tracing::debug!(%username, "webdav login success");
|
||||
Ok(Some(WebDavUser {
|
||||
let tenant_rows: Vec<(Uuid, String)> = 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(&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,
|
||||
_username: user.username,
|
||||
tenants,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -444,10 +520,10 @@ fn build_resources_for_folder(
|
||||
|
||||
let display_name = folder
|
||||
.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 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 {
|
||||
href,
|
||||
@@ -471,7 +547,7 @@ fn build_resources_for_folder(
|
||||
is_collection: true,
|
||||
content_length: 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
|
||||
}
|
||||
|
||||
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(
|
||||
chain: &[String],
|
||||
document: &Document,
|
||||
@@ -509,7 +616,7 @@ fn document_to_resource(
|
||||
is_collection: false,
|
||||
content_length: Some(version.size_bytes),
|
||||
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())
|
||||
}
|
||||
|
||||
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 {
|
||||
_folder: Option<Folder>,
|
||||
subfolders: Vec<Folder>,
|
||||
@@ -635,9 +716,10 @@ struct DavResource {
|
||||
content_type: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
}
|
||||
|
||||
enum ResolvedPath {
|
||||
Root,
|
||||
TenantRoot {
|
||||
chain: Vec<String>,
|
||||
},
|
||||
Folder {
|
||||
folder: Folder,
|
||||
chain: Vec<String>,
|
||||
@@ -649,37 +731,37 @@ enum ResolvedPath {
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
|
||||
if segments.is_empty() {
|
||||
return Ok(Some(ResolvedPath::Root));
|
||||
}
|
||||
|
||||
let mut conn = state.db()?;
|
||||
fn resolve_path(
|
||||
state: &AppState,
|
||||
tenant: &TenantEntry,
|
||||
segments: &[String],
|
||||
) -> AppResult<Option<ResolvedPath>> {
|
||||
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
|
||||
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;
|
||||
|
||||
if segments.is_empty() {
|
||||
return Ok(Some(ResolvedPath::TenantRoot { chain }));
|
||||
}
|
||||
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let is_last = index == segments.len() - 1;
|
||||
|
||||
match find_folder_by_name(&mut conn, parent_id, segment)? {
|
||||
Some(folder) => {
|
||||
if is_last {
|
||||
chain.push(folder.name.clone());
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
|
||||
parent_id = Some(folder.id);
|
||||
chain.push(folder.name.clone());
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
|
||||
{
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
return Ok(Some(ResolvedPath::Folder { folder, chain }));
|
||||
}
|
||||
None => {}
|
||||
parent_id = Some(folder.id);
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_last {
|
||||
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());
|
||||
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 Some(folder) = folders_dsl::folders
|
||||
.find(uuid)
|
||||
.first::<Folder>(&mut conn)
|
||||
.optional()?
|
||||
{
|
||||
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
|
||||
if folder.parent_id != parent_id {
|
||||
return Ok(None);
|
||||
}
|
||||
if !is_last {
|
||||
parent_id = Some(folder.id);
|
||||
chain.push(folder.name.clone());
|
||||
current_folder = Some(folder);
|
||||
continue;
|
||||
} else {
|
||||
chain.push(folder.name.clone());
|
||||
chain.push(folder.name.clone());
|
||||
if is_last {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -744,32 +809,46 @@ fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<Resol
|
||||
|
||||
fn find_folder_by_name(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
name: &str,
|
||||
) -> AppResult<Option<Folder>> {
|
||||
let result = match parent_id {
|
||||
Some(parent) => folders_dsl::folders
|
||||
.filter(folders_dsl::parent_id.eq(Some(parent)))
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?,
|
||||
None => folders_dsl::folders
|
||||
.filter(folders_dsl::parent_id.is_null())
|
||||
.filter(folders_dsl::name.eq(name))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?,
|
||||
let mut query = folders_dsl::folders
|
||||
.filter(folders_dsl::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
query = match parent_id {
|
||||
Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))),
|
||||
None => query.filter(folders_dsl::parent_id.is_null()),
|
||||
};
|
||||
|
||||
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(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
filename: &str,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
let mut query = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.filter(documents_dsl::filename.eq(filename))
|
||||
.into_boxed();
|
||||
|
||||
@@ -790,10 +869,12 @@ fn find_document_by_filename(
|
||||
|
||||
fn find_document_by_id(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
) -> AppResult<Option<(Document, DocumentVersion)>> {
|
||||
if let Some(document) = documents_dsl::documents
|
||||
.filter(documents_dsl::deleted_at.is_null())
|
||||
.filter(documents_dsl::tenant_id.eq(tenant_id))
|
||||
.find(document_id)
|
||||
.first::<Document>(conn)
|
||||
.optional()?
|
||||
|
||||
+63
-3
@@ -8,6 +8,18 @@ diesel::table! {
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
document_asset_objects (id) {
|
||||
id -> Uuid,
|
||||
asset_id -> Uuid,
|
||||
ordinal -> Int4,
|
||||
s3_key -> Text,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +28,11 @@ diesel::table! {
|
||||
id -> Uuid,
|
||||
document_version_id -> Uuid,
|
||||
asset_type -> Text,
|
||||
s3_key -> Text,
|
||||
mime_type -> Text,
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
cardinality -> Nullable<Int4>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +44,7 @@ diesel::table! {
|
||||
role -> Varchar,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +54,7 @@ diesel::table! {
|
||||
tag_id -> Uuid,
|
||||
assigned_at -> Timestamptz,
|
||||
assigned_by -> Nullable<Uuid>,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +71,7 @@ diesel::table! {
|
||||
created_at -> Timestamptz,
|
||||
operations_summary -> Jsonb,
|
||||
metadata -> Jsonb,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +93,7 @@ diesel::table! {
|
||||
#[max_length = 255]
|
||||
title -> Varchar,
|
||||
current_version_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +105,7 @@ diesel::table! {
|
||||
parent_id -> Nullable<Uuid>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +120,7 @@ diesel::table! {
|
||||
last_error -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +134,7 @@ diesel::table! {
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
tenant_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +146,31 @@ diesel::table! {
|
||||
#[max_length = 7]
|
||||
color -> Nullable<Varchar>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,25 +181,38 @@ diesel::table! {
|
||||
username -> Varchar,
|
||||
#[max_length = 255]
|
||||
password_hash -> Varchar,
|
||||
#[max_length = 16]
|
||||
role -> Varchar,
|
||||
created_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 -> tenants (tenant_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 -> documents (document_id));
|
||||
diesel::joinable!(document_correspondents -> tenants (tenant_id));
|
||||
diesel::joinable!(document_correspondents -> users (assigned_by));
|
||||
diesel::joinable!(document_tags -> documents (document_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_versions -> tenants (tenant_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!(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!(
|
||||
correspondents,
|
||||
document_asset_objects,
|
||||
document_assets,
|
||||
document_correspondents,
|
||||
document_tags,
|
||||
@@ -164,5 +222,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
jobs,
|
||||
refresh_tokens,
|
||||
tags,
|
||||
tenants,
|
||||
user_memberships,
|
||||
users,
|
||||
);
|
||||
|
||||
+41
-5
@@ -4,43 +4,79 @@ use diesel::{
|
||||
pg::PgConnection,
|
||||
r2d2::{ConnectionManager, PooledConnection},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::JwtService,
|
||||
config::AppConfig,
|
||||
db::PgPool,
|
||||
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)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<AppConfig>,
|
||||
pub storage: Arc<dyn ObjectStorage>,
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
pub jwt: JwtService,
|
||||
pub tenants: TenantService,
|
||||
}
|
||||
|
||||
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(
|
||||
pool: PgPool,
|
||||
config: AppConfig,
|
||||
storage: Arc<dyn ObjectStorage>,
|
||||
jwt: JwtService,
|
||||
) -> Self {
|
||||
let config = Arc::new(config);
|
||||
let tenants = TenantService::new(pool.clone());
|
||||
|
||||
Self {
|
||||
pool,
|
||||
config: Arc::new(config),
|
||||
config,
|
||||
storage,
|
||||
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
|
||||
.get()
|
||||
.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 anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use aws_sdk_s3::presigning::PresigningConfig;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
|
||||
use crate::models::Tenant;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ObjectStorage: Send + Sync + 'static {
|
||||
async fn put_object(
|
||||
@@ -119,3 +123,52 @@ impl ObjectStorage for S3Storage {
|
||||
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 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},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
};
|
||||
|
||||
use super::{JobExecution, JobHandler};
|
||||
@@ -40,7 +41,12 @@ impl JobHandler for AnalyzeDocumentJob {
|
||||
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()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
@@ -51,7 +57,9 @@ impl JobHandler for AnalyzeDocumentJob {
|
||||
};
|
||||
|
||||
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(Err(err)) => {
|
||||
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> {
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
fn analyze_document(
|
||||
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
|
||||
.find(payload.document_version_id)
|
||||
@@ -88,12 +102,15 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
|
||||
let (supported, reason) = determine_thumbnail_support(&document);
|
||||
let ocr_supported = document_is_pdf(&document);
|
||||
|
||||
let existing_ocr: Option<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
@@ -129,6 +146,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
|
||||
if supported {
|
||||
let enqueue_result = enqueue_job(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
JOB_GENERATE_THUMBNAILS,
|
||||
json!({
|
||||
"document_id": payload.document_id,
|
||||
@@ -146,6 +164,7 @@ fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<Job
|
||||
if ocr_supported && !skip_ocr {
|
||||
let enqueue_result = enqueue_job(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
JOB_GENERATE_OCR_TEXT,
|
||||
json!({
|
||||
"document_id": payload.document_id,
|
||||
|
||||
@@ -12,9 +12,10 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
jobs::JOB_INDEX_DOCUMENT_TEXT,
|
||||
models::{Document, DocumentAsset, DocumentVersion},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
models::{Document, DocumentVersion},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
};
|
||||
|
||||
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
|
||||
@@ -39,7 +40,12 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
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()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
@@ -52,16 +58,29 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
let quickwit_endpoint = match &state.config.quickwit_endpoint {
|
||||
Some(endpoint) => endpoint.clone(),
|
||||
None => {
|
||||
warn!("quickwit endpoint missing; skipping indexing");
|
||||
return JobExecution::Success;
|
||||
return JobExecution::Failed {
|
||||
error: "quickwit endpoint missing".into(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let quickwit_index = match &state.config.quickwit_index {
|
||||
Some(index) => index.clone(),
|
||||
let tenant = match state.tenants.get_by_id(job.tenant_id) {
|
||||
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 => {
|
||||
warn!("quickwit index missing; skipping indexing");
|
||||
return JobExecution::Success;
|
||||
return JobExecution::Failed {
|
||||
error: "tenant quickwit index not configured".into(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,15 +106,15 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
}
|
||||
};
|
||||
|
||||
if context.text_asset.is_none() {
|
||||
if context.text_s3_key.is_none() {
|
||||
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
|
||||
return JobExecution::Failed {
|
||||
error: "missing OCR text asset".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let asset = context.text_asset.unwrap();
|
||||
let text = match state.storage.get_object(&asset.s3_key).await {
|
||||
let s3_key = context.text_s3_key.unwrap();
|
||||
let text = match storage.get_object(&s3_key).await {
|
||||
Ok(bytes) => match String::from_utf8(bytes) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
@@ -129,6 +148,7 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
let payload = json!({
|
||||
"document_id": context.document.id,
|
||||
"version_id": context.version.id,
|
||||
"tenant_id": job.tenant_id,
|
||||
"title": context.document.title.to_lowercase(),
|
||||
"text": text.to_lowercase()
|
||||
});
|
||||
@@ -169,15 +189,15 @@ impl JobHandler for IndexDocumentTextJob {
|
||||
struct IndexContext {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
text_asset: Option<DocumentAsset>,
|
||||
text_s3_key: Option<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
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut conn)
|
||||
.first(&mut base_conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if version.document_id != payload.document_id {
|
||||
@@ -186,12 +206,26 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
|
||||
|
||||
let document: Document = documents::table
|
||||
.find(payload.document_id)
|
||||
.first(&mut conn)
|
||||
.first(&mut base_conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let text_asset: Option<DocumentAsset> = document_assets::table
|
||||
let tenant_id = document.tenant_id;
|
||||
drop(base_conn);
|
||||
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let text_s3_key: Option<String> = document_asset_objects::table
|
||||
.inner_join(
|
||||
document_assets::table.on(document_asset_objects::asset_id.eq(document_assets::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::tenant_id.eq(tenant_id))
|
||||
.filter(document_asset_objects::ordinal.eq(1))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.select(document_asset_objects::s3_key)
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
@@ -199,6 +233,6 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
|
||||
Ok(IndexContext {
|
||||
document,
|
||||
version,
|
||||
text_asset,
|
||||
text_s3_key,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
|
||||
models::Job,
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
};
|
||||
|
||||
pub mod analyze;
|
||||
@@ -25,7 +26,7 @@ pub enum JobExecution {
|
||||
#[async_trait]
|
||||
pub trait JobHandler: Send + Sync {
|
||||
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 {
|
||||
@@ -71,7 +72,7 @@ impl Worker {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut conn = match self.state.db() {
|
||||
let mut conn = match self.state.db_unscoped() {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
error!(?err, "failed to obtain database connection in worker");
|
||||
@@ -84,10 +85,22 @@ impl Worker {
|
||||
|
||||
if let Some(job) = job_opt {
|
||||
if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
|
||||
let result = handler.handle(self.state.clone(), job.clone()).await;
|
||||
match result {
|
||||
let execution = match self.state.storage_for_tenant(job.tenant_id) {
|
||||
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 => {
|
||||
if let Ok(mut conn) = self.state.db() {
|
||||
if let Ok(mut conn) = self.state.db_unscoped() {
|
||||
mark_job_succeeded(&mut conn, job.id)?;
|
||||
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
|
||||
} else {
|
||||
@@ -96,7 +109,7 @@ impl Worker {
|
||||
}
|
||||
JobExecution::Retry { delay, error } => {
|
||||
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)?;
|
||||
} else {
|
||||
error!("failed to requeue job for retry due to pool error");
|
||||
@@ -104,7 +117,7 @@ impl Worker {
|
||||
}
|
||||
JobExecution::Failed { error } => {
|
||||
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)?;
|
||||
} else {
|
||||
error!("failed to mark job failed due to pool error");
|
||||
@@ -113,7 +126,7 @@ impl Worker {
|
||||
}
|
||||
} else {
|
||||
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")?;
|
||||
} else {
|
||||
error!("failed to mark job failed for missing handler due to pool error");
|
||||
|
||||
+119
-30
@@ -19,9 +19,14 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
||||
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
models::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
NewDocumentAssetObject,
|
||||
},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
utils::storage_paths::document_asset_object_prefix,
|
||||
};
|
||||
|
||||
use super::{JobExecution, JobHandler};
|
||||
@@ -51,7 +56,12 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
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()) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
@@ -88,7 +98,7 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
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,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
|
||||
@@ -123,19 +133,24 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
};
|
||||
};
|
||||
|
||||
let asset_id = context
|
||||
.existing_asset
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
if context.existing_asset.is_some() {
|
||||
for object in &context.existing_objects {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
context.document.id, context.version.version_number, OCR_TEXT_ASSET_TYPE, asset_id
|
||||
let asset_id = Uuid::new_v4();
|
||||
|
||||
let s3_key = document_asset_object_prefix(
|
||||
context.document.id,
|
||||
context.version.version_number,
|
||||
OCR_TEXT_ASSET_TYPE,
|
||||
asset_id,
|
||||
);
|
||||
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
if let Err(err) = storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
generation.text.into_bytes(),
|
||||
@@ -158,11 +173,8 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
.await
|
||||
{
|
||||
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
|
||||
}
|
||||
@@ -193,6 +205,7 @@ struct OcrContext {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
existing_asset: Option<DocumentAsset>,
|
||||
existing_objects: Vec<DocumentAssetObject>,
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
@@ -202,11 +215,11 @@ struct OcrGeneration {
|
||||
}
|
||||
|
||||
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
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut conn)
|
||||
.first(&mut base_conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if version.document_id != payload.document_id {
|
||||
@@ -215,32 +228,53 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
|
||||
|
||||
let document: Document = documents::table
|
||||
.find(payload.document_id)
|
||||
.first(&mut conn)
|
||||
.first(&mut base_conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing: Option<DocumentAsset> = document_assets::table
|
||||
let tenant_id = document.tenant_id;
|
||||
drop(base_conn);
|
||||
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_asset: Option<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
||||
document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.order(document_asset_objects::ordinal.asc())
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let is_pdf = document_is_pdf(&document);
|
||||
if !is_pdf {
|
||||
return Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
existing_asset: existing,
|
||||
existing_asset: existing_asset,
|
||||
existing_objects,
|
||||
skip: true,
|
||||
});
|
||||
}
|
||||
|
||||
let skip = existing.is_some() && !payload.force;
|
||||
let skip = existing_asset.is_some() && !payload.force;
|
||||
|
||||
Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
existing_asset: existing,
|
||||
existing_asset,
|
||||
existing_objects,
|
||||
skip,
|
||||
})
|
||||
}
|
||||
@@ -369,18 +403,28 @@ fn persist_ocr_metadata(
|
||||
s3_key: &str,
|
||||
source: &'static str,
|
||||
) -> 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 {
|
||||
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
|
||||
let new_asset = NewDocumentAsset {
|
||||
id: asset_id,
|
||||
document_version_id: context.version.id,
|
||||
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
|
||||
s3_key: s3_key.to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
metadata: json!({
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
"source": source,
|
||||
}),
|
||||
cardinality: Some(1),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_assets::table)
|
||||
@@ -391,9 +435,43 @@ fn persist_ocr_metadata(
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_object_id: Option<Uuid> = document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset_id))
|
||||
.filter(document_asset_objects::ordinal.eq(1))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.select(document_asset_objects::id)
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
|
||||
|
||||
let new_object = NewDocumentAssetObject {
|
||||
id: object_id,
|
||||
asset_id,
|
||||
ordinal: 1,
|
||||
s3_key: s3_key.to_string(),
|
||||
metadata: json!({}),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_asset_objects::table)
|
||||
.values(&new_object)
|
||||
.on_conflict((
|
||||
document_asset_objects::asset_id,
|
||||
document_asset_objects::ordinal,
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
|
||||
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
@@ -402,9 +480,20 @@ fn persist_ocr_metadata(
|
||||
}
|
||||
|
||||
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(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
JOB_INDEX_DOCUMENT_TEXT,
|
||||
json!({
|
||||
"document_id": payload.document_id,
|
||||
|
||||
+348
-127
@@ -13,9 +13,14 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
jobs::JOB_GENERATE_THUMBNAILS,
|
||||
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
|
||||
schema::{document_assets, document_versions, documents},
|
||||
models::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
NewDocumentAssetObject,
|
||||
},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
utils::storage_paths::document_asset_object_key,
|
||||
};
|
||||
|
||||
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
||||
@@ -49,7 +54,12 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
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()) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
@@ -85,7 +95,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
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,
|
||||
Err(err) => {
|
||||
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
|
||||
@@ -142,86 +152,134 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
}
|
||||
}
|
||||
|
||||
let thumbnail_asset_id = initial
|
||||
.existing_thumbnail
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
let thumbnail_s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
THUMBNAIL_ASSET_TYPE,
|
||||
thumbnail_asset_id
|
||||
);
|
||||
|
||||
let preview_asset_id = initial
|
||||
.existing_preview
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
let preview_s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
PREVIEW_ASSET_TYPE,
|
||||
preview_asset_id
|
||||
);
|
||||
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
.put_object(
|
||||
&preview_s3_key,
|
||||
generation.preview.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
if initial.existing_preview.is_some() {
|
||||
for object in &initial.existing_preview_objects {
|
||||
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 preview object"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
.put_object(
|
||||
&thumbnail_s3_key,
|
||||
generation.thumbnail.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
if initial.existing_thumbnail.is_some() {
|
||||
for object in &initial.existing_thumbnail_objects {
|
||||
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 thumbnail object"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let preview_asset_id = Uuid::new_v4();
|
||||
|
||||
let thumbnail_asset_id = Uuid::new_v4();
|
||||
|
||||
let mut preview_objects: Vec<AssetObjectPersistence> =
|
||||
Vec::with_capacity(generation.preview.objects.len());
|
||||
for (index, image) in generation.preview.objects.iter().enumerate() {
|
||||
if index + 1 > i32::MAX as usize {
|
||||
return JobExecution::Failed {
|
||||
error: "too many preview objects".to_string(),
|
||||
};
|
||||
}
|
||||
let ordinal = (index + 1) as i32;
|
||||
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) = storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
image.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
preview_objects.push(AssetObjectPersistence {
|
||||
ordinal,
|
||||
s3_key,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
|
||||
let mut thumbnail_objects: Vec<AssetObjectPersistence> =
|
||||
Vec::with_capacity(generation.thumbnail.objects.len());
|
||||
for (index, image) in generation.thumbnail.objects.iter().enumerate() {
|
||||
if index + 1 > i32::MAX as usize {
|
||||
return JobExecution::Failed {
|
||||
error: "too many thumbnail objects".to_string(),
|
||||
};
|
||||
}
|
||||
let ordinal = (index + 1) as i32;
|
||||
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) = storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
image.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
thumbnail_objects.push(AssetObjectPersistence {
|
||||
ordinal,
|
||||
s3_key,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
}
|
||||
|
||||
let asset_persistences = vec![
|
||||
AssetPersistence {
|
||||
asset_type: PREVIEW_ASSET_TYPE,
|
||||
asset_id: preview_asset_id,
|
||||
objects: preview_objects,
|
||||
},
|
||||
AssetPersistence {
|
||||
asset_type: THUMBNAIL_ASSET_TYPE,
|
||||
asset_id: thumbnail_asset_id,
|
||||
objects: thumbnail_objects,
|
||||
},
|
||||
];
|
||||
|
||||
let state_clone = state.clone();
|
||||
match task::spawn_blocking(move || {
|
||||
persist_assets_metadata(
|
||||
state_clone,
|
||||
&initial,
|
||||
&[
|
||||
AssetPersistence {
|
||||
asset_type: PREVIEW_ASSET_TYPE,
|
||||
asset_id: preview_asset_id,
|
||||
s3_key: &preview_s3_key,
|
||||
generated: &generation.preview,
|
||||
},
|
||||
AssetPersistence {
|
||||
asset_type: THUMBNAIL_ASSET_TYPE,
|
||||
asset_id: thumbnail_asset_id,
|
||||
s3_key: &thumbnail_s3_key,
|
||||
generated: &generation.thumbnail,
|
||||
},
|
||||
],
|
||||
)
|
||||
persist_assets_metadata(state_clone, &initial, &asset_persistences)
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -250,7 +308,9 @@ struct ThumbnailContext {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
existing_thumbnail: Option<DocumentAsset>,
|
||||
existing_thumbnail_objects: Vec<DocumentAssetObject>,
|
||||
existing_preview: Option<DocumentAsset>,
|
||||
existing_preview_objects: Vec<DocumentAssetObject>,
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
@@ -260,24 +320,34 @@ struct GeneratedImage {
|
||||
height: Option<i32>,
|
||||
}
|
||||
|
||||
struct GeneratedAsset {
|
||||
objects: Vec<GeneratedImage>,
|
||||
}
|
||||
|
||||
struct GeneratedAssets {
|
||||
thumbnail: GeneratedImage,
|
||||
preview: GeneratedImage,
|
||||
thumbnail: GeneratedAsset,
|
||||
preview: GeneratedAsset,
|
||||
page_count: Option<u32>,
|
||||
}
|
||||
|
||||
struct AssetPersistence<'a> {
|
||||
struct AssetObjectPersistence {
|
||||
ordinal: i32,
|
||||
s3_key: String,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
}
|
||||
|
||||
struct AssetPersistence {
|
||||
asset_type: &'static str,
|
||||
asset_id: Uuid,
|
||||
s3_key: &'a str,
|
||||
generated: &'a GeneratedImage,
|
||||
objects: Vec<AssetObjectPersistence>,
|
||||
}
|
||||
|
||||
fn load_thumbnail_context(
|
||||
state: Arc<AppState>,
|
||||
payload: &ThumbnailPayload,
|
||||
) -> 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
|
||||
.find(payload.document_version_id)
|
||||
@@ -293,21 +363,42 @@ fn load_thumbnail_context(
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let tenant_id = document.tenant_id;
|
||||
|
||||
let existing_assets: Vec<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq_any(vec![
|
||||
THUMBNAIL_ASSET_TYPE.to_string(),
|
||||
PREVIEW_ASSET_TYPE.to_string(),
|
||||
]))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let mut existing_thumbnail = None;
|
||||
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
|
||||
let mut existing_preview = None;
|
||||
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
|
||||
for asset in existing_assets {
|
||||
match asset.asset_type.as_str() {
|
||||
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
|
||||
PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
|
||||
THUMBNAIL_ASSET_TYPE => {
|
||||
existing_thumbnail_objects = document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.order(document_asset_objects::ordinal.asc())
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
existing_thumbnail = Some(asset);
|
||||
}
|
||||
PREVIEW_ASSET_TYPE => {
|
||||
existing_preview_objects = document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id))
|
||||
.order(document_asset_objects::ordinal.asc())
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
existing_preview = Some(asset);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -317,13 +408,33 @@ fn load_thumbnail_context(
|
||||
return Err("thumbnail generation not supported for this document".into());
|
||||
}
|
||||
|
||||
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
|
||||
let expected_cardinality = expected_asset_cardinality(&document, &version);
|
||||
let preview_cardinality = existing_preview
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.cardinality)
|
||||
.unwrap_or_else(|| existing_preview_objects.len() as i32);
|
||||
let thumbnail_cardinality = existing_thumbnail
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.cardinality)
|
||||
.unwrap_or_else(|| existing_thumbnail_objects.len() as i32);
|
||||
|
||||
let needs_regeneration = preview_cardinality < expected_cardinality
|
||||
|| thumbnail_cardinality < expected_cardinality
|
||||
|| (existing_preview_objects.len() as i32) < expected_cardinality
|
||||
|| (existing_thumbnail_objects.len() as i32) < expected_cardinality;
|
||||
|
||||
let skip = existing_thumbnail.is_some()
|
||||
&& existing_preview.is_some()
|
||||
&& !payload.force
|
||||
&& !needs_regeneration;
|
||||
|
||||
Ok(ThumbnailContext {
|
||||
document,
|
||||
version,
|
||||
existing_thumbnail,
|
||||
existing_thumbnail_objects,
|
||||
existing_preview,
|
||||
existing_preview_objects,
|
||||
skip,
|
||||
})
|
||||
}
|
||||
@@ -332,18 +443,7 @@ fn generate_preview_and_thumbnail(
|
||||
document: &Document,
|
||||
bytes: &[u8],
|
||||
) -> Result<GeneratedAssets, String> {
|
||||
let is_pdf = document
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(|mime| mime == "application/pdf")
|
||||
.unwrap_or_else(|| {
|
||||
document
|
||||
.original_name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let is_pdf = document_is_pdf(document);
|
||||
|
||||
if is_pdf {
|
||||
let pdf_assets = generate_pdf_assets(bytes)?;
|
||||
@@ -362,7 +462,7 @@ fn generate_preview_and_thumbnail(
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
||||
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> {
|
||||
let reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|err| err.to_string())?;
|
||||
@@ -384,12 +484,19 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage
|
||||
let preview = encode_dynamic_image(preview_image)?;
|
||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||
|
||||
Ok((preview, thumbnail))
|
||||
Ok((
|
||||
GeneratedAsset {
|
||||
objects: vec![preview],
|
||||
},
|
||||
GeneratedAsset {
|
||||
objects: vec![thumbnail],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
struct PdfGeneratedAssets {
|
||||
preview: GeneratedImage,
|
||||
thumbnail: GeneratedImage,
|
||||
preview: GeneratedAsset,
|
||||
thumbnail: GeneratedAsset,
|
||||
page_count: u32,
|
||||
}
|
||||
|
||||
@@ -402,11 +509,7 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
||||
.map_err(|err| format!("load pdf: {err}"))?;
|
||||
|
||||
let pages = document.pages();
|
||||
let total_pages = pages.len();
|
||||
|
||||
let page = pages
|
||||
.get(0)
|
||||
.map_err(|err| format!("load first page: {err}"))?;
|
||||
let total_pages = pages.len() as usize;
|
||||
|
||||
let render_config = PdfRenderConfig::new()
|
||||
.set_target_width(PREVIEW_WIDTH as i32)
|
||||
@@ -414,30 +517,44 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
||||
.render_form_data(true)
|
||||
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
||||
|
||||
let bitmap = page
|
||||
.render_with_config(&render_config)
|
||||
.map_err(|err| format!("render pdf page: {err}"))?;
|
||||
let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||
let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||
|
||||
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||
for page_index in 0..total_pages {
|
||||
let page = pages
|
||||
.get(u16::try_from(page_index).map_err(|_| "page index overflow".to_string())?)
|
||||
.map_err(|err| format!("load page {page_index}: {err}"))?;
|
||||
|
||||
let thumbnail_image =
|
||||
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
||||
let bitmap = page
|
||||
.render_with_config(&render_config)
|
||||
.map_err(|err| format!("render pdf page {page_index}: {err}"))?;
|
||||
|
||||
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||
|
||||
let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH
|
||||
|| preview_image.height() > THUMBNAIL_HEIGHT
|
||||
{
|
||||
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||
} else {
|
||||
preview_image.clone()
|
||||
};
|
||||
|
||||
let preview = encode_dynamic_image(preview_image)?;
|
||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||
preview_objects.push(encode_dynamic_image(preview_image)?);
|
||||
thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?);
|
||||
}
|
||||
|
||||
let page_count: u32 = total_pages
|
||||
.try_into()
|
||||
.map_err(|_| "page count exceeds supported range".to_string())?;
|
||||
|
||||
Ok(PdfGeneratedAssets {
|
||||
preview,
|
||||
thumbnail,
|
||||
preview: GeneratedAsset {
|
||||
objects: preview_objects,
|
||||
},
|
||||
thumbnail: GeneratedAsset {
|
||||
objects: thumbnail_objects,
|
||||
},
|
||||
page_count,
|
||||
})
|
||||
}
|
||||
@@ -458,22 +575,51 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
|
||||
fn persist_assets_metadata(
|
||||
state: Arc<AppState>,
|
||||
context: &ThumbnailContext,
|
||||
assets: &[AssetPersistence<'_>],
|
||||
assets: &[AssetPersistence],
|
||||
) -> 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 {
|
||||
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
|
||||
if let Some(existing_thumbnail) = &context.existing_thumbnail {
|
||||
diesel::delete(
|
||||
document_assets::table.filter(document_assets::id.eq(existing_thumbnail.id)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
|
||||
for asset in assets {
|
||||
if asset.objects.is_empty() {
|
||||
return Err(format!(
|
||||
"asset {} has no generated objects",
|
||||
asset.asset_type
|
||||
));
|
||||
}
|
||||
|
||||
let object_count: i32 = asset
|
||||
.objects
|
||||
.len()
|
||||
.try_into()
|
||||
.map_err(|_| "asset contains too many objects".to_string())?;
|
||||
|
||||
let new_asset = NewDocumentAsset {
|
||||
id: asset.asset_id,
|
||||
document_version_id: context.version.id,
|
||||
asset_type: asset.asset_type.to_string(),
|
||||
s3_key: asset.s3_key.to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
metadata: json!({
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
"width": asset.generated.width,
|
||||
"height": asset.generated.height,
|
||||
}),
|
||||
cardinality: Some(object_count),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_assets::table)
|
||||
@@ -484,12 +630,46 @@ fn persist_assets_metadata(
|
||||
))
|
||||
.do_update()
|
||||
.set((
|
||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
diesel::delete(
|
||||
document_asset_objects::table
|
||||
.filter(document_asset_objects::asset_id.eq(asset.asset_id))
|
||||
.filter(document_asset_objects::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
for object in &asset.objects {
|
||||
let mut metadata_map = Map::new();
|
||||
if let Some(width) = object.width {
|
||||
metadata_map.insert("width".to_string(), Value::from(width));
|
||||
}
|
||||
if let Some(height) = object.height {
|
||||
metadata_map.insert("height".to_string(), Value::from(height));
|
||||
}
|
||||
|
||||
let object_metadata = Value::Object(metadata_map);
|
||||
|
||||
let new_object = NewDocumentAssetObject {
|
||||
id: Uuid::new_v4(),
|
||||
asset_id: asset.asset_id,
|
||||
ordinal: object.ordinal,
|
||||
s3_key: object.s3_key.clone(),
|
||||
metadata: object_metadata,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(document_asset_objects::table)
|
||||
.values(&new_object)
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -501,11 +681,17 @@ fn persist_document_page_count(
|
||||
document_version_id: Uuid,
|
||||
page_count: u32,
|
||||
) -> 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
|
||||
.filter(document_versions::id.eq(document_version_id))
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::tenant_id.eq(tenant_id))
|
||||
.select(document_versions::metadata)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
@@ -525,7 +711,8 @@ fn persist_document_page_count(
|
||||
diesel::update(
|
||||
document_versions::table
|
||||
.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))
|
||||
.execute(&mut conn)
|
||||
@@ -533,3 +720,37 @@ fn persist_document_page_count(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn document_is_pdf(document: &Document) -> bool {
|
||||
document
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
|
||||
.unwrap_or_else(|| {
|
||||
document
|
||||
.original_name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn expected_asset_cardinality(document: &Document, version: &DocumentVersion) -> i32 {
|
||||
if let Value::Object(map) = &version.metadata {
|
||||
if let Some(count) = map.get("page_count").and_then(|v| v.as_i64()) {
|
||||
if count > 0 {
|
||||
return count
|
||||
.min(i64::from(i32::MAX))
|
||||
.try_into()
|
||||
.unwrap_or(i32::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if document_is_pdf(document) {
|
||||
1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use serde::Deserialize;
|
||||
#[derive(Deserialize)]
|
||||
struct AuthenticatedUser {
|
||||
username: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -27,7 +26,6 @@ async fn login_and_me_roundtrip() -> Result<()> {
|
||||
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||
|
||||
assert_eq!(user.username, "alice");
|
||||
assert_eq!(user.role, "admin");
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -11,7 +11,7 @@ use axum::Router;
|
||||
use backend::auth::jwt::JwtService;
|
||||
use backend::config::AppConfig;
|
||||
use backend::db::{self, PgPool};
|
||||
use backend::models::{Job, NewUser};
|
||||
use backend::models::{Job, NewUser, NewUserMembership};
|
||||
use backend::routes;
|
||||
use backend::state::AppState;
|
||||
use backend::storage::ObjectStorage;
|
||||
@@ -22,7 +22,8 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use http_body_util::BodyExt;
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tokio::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
@@ -138,6 +139,7 @@ impl TestApp {
|
||||
s3_bucket: "test-bucket".to_string(),
|
||||
quickwit_endpoint: 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)?;
|
||||
@@ -178,18 +180,34 @@ impl TestApp {
|
||||
let username = username.to_string();
|
||||
let password = password.to_string();
|
||||
let role = role.to_string();
|
||||
let tenant_id = self
|
||||
.state
|
||||
.tenants
|
||||
.tenant_id_for_slug(&self.state.config.default_tenant_slug)
|
||||
.context("default tenant not found")?;
|
||||
self.with_conn(move |conn| {
|
||||
let password_hash = hash_password(&password)?;
|
||||
let user = NewUser {
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
};
|
||||
diesel::insert_into(backend::schema::users::table)
|
||||
.values(&user)
|
||||
.execute(conn)
|
||||
.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)
|
||||
})
|
||||
.await
|
||||
@@ -217,11 +235,57 @@ impl TestApp {
|
||||
);
|
||||
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
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 {
|
||||
selection_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.selection_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)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,12 @@ struct DocumentDownload {
|
||||
filename: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkReanalyze {
|
||||
queued: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BulkMoveResult {
|
||||
updated: usize,
|
||||
}
|
||||
@@ -318,97 +318,6 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_reanalyze_documents() -> Result<()> {
|
||||
let _lock = acquire_db_lock().await;
|
||||
let app = TestApp::new().await?;
|
||||
|
||||
let password = "bulkpass";
|
||||
app.insert_user("alex", password, "admin").await?;
|
||||
let token = app.login_token("alex", password).await?;
|
||||
|
||||
app.clear_jobs().await?;
|
||||
|
||||
let first_bytes = b"first doc";
|
||||
let first = app
|
||||
.upload_document(
|
||||
"/api/documents",
|
||||
"first.txt",
|
||||
"text/plain",
|
||||
first_bytes,
|
||||
None,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
let first_body = body_to_vec(first.into_body()).await?;
|
||||
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||
|
||||
let second_bytes = b"second doc";
|
||||
let second = app
|
||||
.upload_document(
|
||||
"/api/documents",
|
||||
"second.txt",
|
||||
"text/plain",
|
||||
second_bytes,
|
||||
None,
|
||||
&token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(second.status(), StatusCode::CREATED);
|
||||
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 response = app
|
||||
.post_json(
|
||||
"/api/documents/reanalyze",
|
||||
&serde_json::json!({}),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
let body = body_to_vec(response.into_body()).await?;
|
||||
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
|
||||
assert_eq!(bulk.queued, 2);
|
||||
|
||||
let jobs = app.jobs_by_type("analyze-document").await?;
|
||||
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 mut expected = vec![
|
||||
(
|
||||
first_detail.document.id,
|
||||
first_detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("first current version")
|
||||
.id,
|
||||
),
|
||||
(
|
||||
second_detail.document.id,
|
||||
second_detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("second current version")
|
||||
.id,
|
||||
),
|
||||
];
|
||||
payload_docs.sort();
|
||||
expected.sort();
|
||||
assert_eq!(payload_docs, expected);
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_move_documents_to_folder() -> Result<()> {
|
||||
|
||||
+33
-14
@@ -10,19 +10,19 @@ services:
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U papercrate"]
|
||||
test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate_test"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
quickwit:
|
||||
quickwit-test:
|
||||
image: quickwit/quickwit:0.8.2
|
||||
command: ["run"]
|
||||
environment:
|
||||
QW_ENABLE_API_AUTH: "false"
|
||||
QW_DATA_DIR: /quickwit/data
|
||||
ports:
|
||||
- "7280:7280"
|
||||
- "7281:7280"
|
||||
volumes:
|
||||
- quickwit_test_data:/quickwit/data
|
||||
healthcheck:
|
||||
@@ -31,21 +31,40 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
quickwit-create-index:
|
||||
image: quickwit/quickwit:0.8.2
|
||||
admin-bootstrap:
|
||||
build:
|
||||
context: ./backend
|
||||
depends_on:
|
||||
quickwit:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
quickwit-test:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- quickwit_test_data:/quickwit/data
|
||||
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro
|
||||
environment:
|
||||
QW_DATA_DIR: /quickwit/data
|
||||
QW_NODE_URI: http://quickwit:7280
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
DATABASE_URL: postgres://papercrate:papercrate_test@postgres-test:5432/papercrate_test
|
||||
DATABASE_MAX_POOL_SIZE: 1
|
||||
S3_BUCKET: documents
|
||||
JWT_SECRET: change-me-super-secret
|
||||
QUICKWIT_ENDPOINT: http://quickwit-test:7280
|
||||
DEFAULT_TENANT_SLUG: admin
|
||||
entrypoint: []
|
||||
command: >
|
||||
quickwit index list --node-uri http://quickwit:7280 2>/dev/null | grep -q '"documents"' \
|
||||
|| quickwit index create --index-config /tmp/documents-index.yaml --node-uri http://quickwit:7280
|
||||
/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"
|
||||
|
||||
volumes:
|
||||
|
||||
+35
-6
@@ -62,15 +62,44 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
quickwit-create-index:
|
||||
image: quickwit/quickwit:0.8.2
|
||||
admin-bootstrap:
|
||||
build:
|
||||
context: ./backend
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
quickwit:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- quickwit_data:/quickwit/data
|
||||
- ./quickwit/documents-index.yaml:/tmp/documents-index.yaml:ro
|
||||
command: index create --endpoint=http://quickwit:7280 --index-config /tmp/documents-index.yaml
|
||||
environment:
|
||||
DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate
|
||||
DATABASE_MAX_POOL_SIZE: 2
|
||||
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"
|
||||
|
||||
volumes:
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@ Documents
|
||||
---------
|
||||
- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info.
|
||||
- POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields).
|
||||
- POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document.
|
||||
- POST /api/documents/bulk/move - Move multiple documents to a target folder.
|
||||
- POST /api/documents/bulk/tags - Add or remove tags across multiple documents.
|
||||
- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Default `action=add` replaces existing assignments for the provided roles before adding the supplied correspondents; `action=remove` drops the specified correspondent/role pairs.
|
||||
@@ -37,7 +36,7 @@ Document Assets
|
||||
---------------
|
||||
- GET /api/documents/:id/assets - List generated assets for the current version.
|
||||
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
|
||||
- GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset.
|
||||
- GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object).
|
||||
|
||||
Downloads
|
||||
---------
|
||||
|
||||
+1
-1
@@ -44,4 +44,4 @@ npm run build
|
||||
|
||||
## Assets
|
||||
|
||||
- The folder icon (`public/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
|
||||
- The folder icon (`src/assets/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
|
||||
|
||||
Generated
+737
@@ -8,6 +8,7 @@
|
||||
"name": "papercrate-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tabler/icons-react": "3.11.0",
|
||||
"axios": "1.7.7",
|
||||
"react": "18.3.1",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"babel-loader": "9.2.1",
|
||||
"css-loader": "7.1.2",
|
||||
"dotenv": "16.4.5",
|
||||
@@ -641,6 +643,22 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-syntax-typescript": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
|
||||
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-syntax-unicode-sets-regex": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
|
||||
@@ -1300,6 +1318,22 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-constant-elements": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz",
|
||||
"integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-display-name": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
|
||||
@@ -1499,6 +1533,26 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typescript": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz",
|
||||
"integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.27.3",
|
||||
"@babel/helper-create-class-features-plugin": "^7.27.1",
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||
"@babel/plugin-syntax-typescript": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-unicode-escapes": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
|
||||
@@ -1696,6 +1750,26 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/preset-typescript": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
|
||||
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/plugin-syntax-jsx": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
|
||||
"@babel/plugin-transform-typescript": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
@@ -1779,6 +1853,15 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1964,6 +2047,290 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
||||
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
|
||||
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
|
||||
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
|
||||
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
|
||||
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
|
||||
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
|
||||
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-transform-svg-component": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
|
||||
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-preset": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
|
||||
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
|
||||
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
|
||||
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
|
||||
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
|
||||
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
|
||||
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
|
||||
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
|
||||
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/core": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
|
||||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
"camelcase": "^6.2.0",
|
||||
"cosmiconfig": "^8.1.3",
|
||||
"snake-case": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/hast-util-to-babel-ast": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
|
||||
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.21.3",
|
||||
"entities": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/plugin-jsx": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
|
||||
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
"@svgr/hast-util-to-babel-ast": "8.0.0",
|
||||
"svg-parser": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@svgr/core": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/plugin-svgo": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz",
|
||||
"integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cosmiconfig": "^8.1.3",
|
||||
"deepmerge": "^4.3.1",
|
||||
"svgo": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@svgr/core": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/webpack": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz",
|
||||
"integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.21.3",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@svgr/core": "8.1.0",
|
||||
"@svgr/plugin-jsx": "8.1.0",
|
||||
"@svgr/plugin-svgo": "8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz",
|
||||
@@ -1990,6 +2357,16 @@
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -2595,6 +2972,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -2929,6 +3313,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camel-case": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||
@@ -2940,6 +3334,19 @@
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001749",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz",
|
||||
@@ -3170,6 +3577,33 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"import-fresh": "^3.3.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"parse-json": "^5.2.0",
|
||||
"path-type": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.9.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3238,6 +3672,20 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.30",
|
||||
"source-map-js": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
@@ -3264,6 +3712,42 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csso": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
|
||||
"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "~2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csso/node_modules/css-tree": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
|
||||
"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.28",
|
||||
"source-map-js": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csso/node_modules/mdn-data": {
|
||||
"version": "2.0.28",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
|
||||
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -3274,6 +3758,16 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
|
||||
@@ -3535,6 +4029,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -4447,6 +4951,33 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
"resolve-from": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh/node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||
@@ -4494,6 +5025,13 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -4691,6 +5229,19 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -4752,6 +5303,13 @@
|
||||
"shell-quote": "^1.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
@@ -4834,6 +5392,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
@@ -5183,6 +5748,38 @@
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
"json-parse-even-better-errors": "^2.3.0",
|
||||
"lines-and-columns": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -5238,6 +5835,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -6117,6 +6724,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/snake-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
||||
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||
@@ -6321,6 +6939,125 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-parser": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
||||
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svgo": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
|
||||
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@trysound/sax": "0.2.0",
|
||||
"commander": "^7.2.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^2.3.1",
|
||||
"css-what": "^6.1.0",
|
||||
"csso": "^5.0.5",
|
||||
"picocolors": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"svgo": "bin/svgo"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/svgo"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "echo \"No linting configured\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tabler/icons-react": "3.11.0",
|
||||
"axios": "1.7.7",
|
||||
"react": "18.3.1",
|
||||
@@ -19,6 +20,7 @@
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"babel-loader": "9.2.1",
|
||||
"css-loader": "7.1.2",
|
||||
"dotenv": "16.4.5",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export const AppShellContext = React.createContext(null);
|
||||
|
||||
export const useAppShell = () => {
|
||||
const context = React.useContext(AppShellContext);
|
||||
if (!context) {
|
||||
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
+214
-21
@@ -17,7 +17,116 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
|
||||
return getAssetFromGroup(currentVersion.assets, assetType);
|
||||
};
|
||||
|
||||
export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => {
|
||||
const normalizeAssetObjects = (objects) => {
|
||||
if (!Array.isArray(objects)) {
|
||||
return [];
|
||||
}
|
||||
return objects
|
||||
.filter((entry) => Number.isInteger(entry?.ordinal))
|
||||
.slice()
|
||||
.sort((a, b) => a.ordinal - b.ordinal);
|
||||
};
|
||||
|
||||
const mergeAssetObjects = (existingObjects, incomingObjects) => {
|
||||
const merged = new Map();
|
||||
|
||||
normalizeAssetObjects(existingObjects).forEach((entry) => {
|
||||
merged.set(entry.ordinal, { ...entry });
|
||||
});
|
||||
|
||||
normalizeAssetObjects(incomingObjects).forEach((entry) => {
|
||||
const current = merged.get(entry.ordinal) || {};
|
||||
merged.set(entry.ordinal, { ...current, ...entry });
|
||||
});
|
||||
|
||||
return [...merged.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, value]) => value);
|
||||
};
|
||||
|
||||
export class AssetView {
|
||||
constructor(asset) {
|
||||
this.asset = asset || null;
|
||||
this._objectsRef = null;
|
||||
this._sortedObjects = [];
|
||||
}
|
||||
|
||||
getCardinality() {
|
||||
if (!this.asset) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const reported = Number(this.asset.cardinality);
|
||||
if (Number.isFinite(reported) && reported > 0) {
|
||||
return reported;
|
||||
}
|
||||
|
||||
const objectsCount = this.getObjects().length;
|
||||
if (objectsCount > 0) {
|
||||
return objectsCount;
|
||||
}
|
||||
|
||||
return this.asset.metadata ? 1 : 0;
|
||||
}
|
||||
|
||||
getObjects() {
|
||||
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this._objectsRef === this.asset.objects) {
|
||||
return this._sortedObjects;
|
||||
}
|
||||
|
||||
this._objectsRef = this.asset.objects;
|
||||
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
|
||||
return this._sortedObjects;
|
||||
}
|
||||
|
||||
getObject(ordinal = 1) {
|
||||
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
|
||||
if (fromObjects) {
|
||||
return fromObjects;
|
||||
}
|
||||
|
||||
if (ordinal === 1 && this.asset) {
|
||||
if (this.asset.url || this.asset.metadata) {
|
||||
return {
|
||||
ordinal: 1,
|
||||
url: this.asset.url || null,
|
||||
metadata: this.asset.metadata || null,
|
||||
expires_at: this.asset.expiresAt ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getPrimaryObject() {
|
||||
return this.getObject(1);
|
||||
}
|
||||
|
||||
getPrimaryMetadata() {
|
||||
return this.getPrimaryObject()?.metadata || null;
|
||||
}
|
||||
|
||||
getPrimaryUrl() {
|
||||
return this.getPrimaryObject()?.url || null;
|
||||
}
|
||||
|
||||
hasObject(ordinal) {
|
||||
return Boolean(this.getObject(ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
export const createAssetView = (asset) => new AssetView(asset);
|
||||
|
||||
export const resolveDocumentAssetUrl = (
|
||||
doc,
|
||||
type,
|
||||
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
|
||||
) => {
|
||||
if (!doc || !type) {
|
||||
return null;
|
||||
}
|
||||
@@ -25,15 +134,32 @@ export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, e
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
const view = createAssetView(asset);
|
||||
const object = view.getObject(objectOrdinal);
|
||||
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
|
||||
const expiresAt = typeof object?.expires_at === 'number'
|
||||
? object.expires_at
|
||||
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
|
||||
? asset.expiresAt
|
||||
: null;
|
||||
const now = Date.now();
|
||||
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
||||
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now);
|
||||
if (hasFreshUrl) {
|
||||
return asset.url;
|
||||
if (url && (!expiresAt || expiresAt > now)) {
|
||||
return url;
|
||||
}
|
||||
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
||||
const force = Boolean(asset.url && expiresAt && expiresAt <= now);
|
||||
const options = ensureOptions ? { ...ensureOptions, force } : { force };
|
||||
const force = Boolean(url && expiresAt && expiresAt <= now);
|
||||
const options = {
|
||||
force,
|
||||
start: objectOrdinal,
|
||||
limit: 1,
|
||||
...(ensureOptions || {}),
|
||||
};
|
||||
if (!options.start) {
|
||||
options.start = objectOrdinal;
|
||||
}
|
||||
if (!options.limit) {
|
||||
options.limit = 1;
|
||||
}
|
||||
ensureAssetUrl(doc.id, asset, options).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
@@ -63,6 +189,10 @@ class AssetManager {
|
||||
}
|
||||
const cached = this.assetCache.get(asset.id);
|
||||
if (!cached) {
|
||||
const normalized = mergeAssetObjects(null, asset.objects);
|
||||
if (normalized.length) {
|
||||
return { ...asset, objects: normalized };
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
const merged = { ...cached, ...asset };
|
||||
@@ -76,6 +206,10 @@ class AssetManager {
|
||||
merged.expiresAt = cachedExpires;
|
||||
}
|
||||
}
|
||||
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
|
||||
if (mergedObjects.length) {
|
||||
merged.objects = mergedObjects;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -172,24 +306,53 @@ class AssetManager {
|
||||
return next;
|
||||
}
|
||||
|
||||
ensureAsset(documentId, asset, { force = false } = {}) {
|
||||
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
|
||||
if (!documentId || !asset?.id) {
|
||||
return Promise.resolve(asset || null);
|
||||
}
|
||||
|
||||
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
||||
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
|
||||
this.rememberAsset(asset);
|
||||
return Promise.resolve(asset);
|
||||
}
|
||||
const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
|
||||
const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
|
||||
const requestedEnd = requestedStart + requestedLimit - 1;
|
||||
|
||||
const cached = this.assetCache.get(asset.id);
|
||||
const baseAsset = this.assetCache.get(asset.id) || asset;
|
||||
const view = createAssetView(baseAsset);
|
||||
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
|
||||
const now = Date.now();
|
||||
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
|
||||
return Promise.resolve({ ...asset, ...cached });
|
||||
|
||||
const isOrdinalSatisfied = (ordinal) => {
|
||||
const object = view.getObject(ordinal);
|
||||
if (!object) {
|
||||
return false;
|
||||
}
|
||||
if (!object.url) {
|
||||
return false;
|
||||
}
|
||||
if (typeof object.expires_at === 'number') {
|
||||
return object.expires_at > now;
|
||||
}
|
||||
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
let needsFetch = force;
|
||||
if (!needsFetch) {
|
||||
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
|
||||
if (!isOrdinalSatisfied(ordinal)) {
|
||||
needsFetch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inflightKey = `${documentId}:${asset.id}`;
|
||||
if (!needsFetch) {
|
||||
this.rememberAsset(baseAsset);
|
||||
return Promise.resolve(baseAsset);
|
||||
}
|
||||
|
||||
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
|
||||
if (!force && this.assetInflight.has(inflightKey)) {
|
||||
return this.assetInflight.get(inflightKey);
|
||||
}
|
||||
@@ -198,14 +361,44 @@ class AssetManager {
|
||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||
}
|
||||
|
||||
const params = {};
|
||||
if (Number.isInteger(start) && start > 0) {
|
||||
params.start = start;
|
||||
}
|
||||
if (Number.isInteger(limit) && limit > 0) {
|
||||
params.limit = limit;
|
||||
}
|
||||
|
||||
const requestConfig = Object.keys(params).length ? { params } : undefined;
|
||||
|
||||
const request = this.api
|
||||
.get(`/documents/${documentId}/assets/${asset.id}`)
|
||||
.get(`/assets/${asset.id}`, requestConfig)
|
||||
.then(({ data }) => {
|
||||
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
|
||||
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
|
||||
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
|
||||
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
|
||||
const view = createAssetView(combined);
|
||||
const primaryObject = view.getPrimaryObject();
|
||||
const expiresAt = typeof primaryObject?.expires_at === 'number'
|
||||
? primaryObject.expires_at
|
||||
: Date.now() + this.assetPresignTtlMs;
|
||||
const cardinality = (() => {
|
||||
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
|
||||
const objectsCount = mergedObjects.length;
|
||||
if (Number.isFinite(reported) && reported > 0) {
|
||||
return Math.max(reported, objectsCount) || null;
|
||||
}
|
||||
return objectsCount || null;
|
||||
})();
|
||||
|
||||
const entry = {
|
||||
...asset,
|
||||
...data,
|
||||
expiresAt: Date.now() + this.assetPresignTtlMs,
|
||||
...combined,
|
||||
cardinality,
|
||||
url: view.getPrimaryUrl(),
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
this.rememberAsset(entry);
|
||||
return entry;
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
||||
<stop offset="0" stop-color="#62a0ea"/>
|
||||
<stop offset="0.0576991" stop-color="#afd4ff"/>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 128 128" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<defs>
|
||||
<filter id="cornerShadow" x="-20%" y="-20%" width="200%" height="200%">
|
||||
<feDropShadow dx="-4" dy="4" stdDeviation="8" flood-opacity="0.24"/>
|
||||
</filter>
|
||||
<linearGradient id="cornerGradient" gradientUnits="userSpaceOnUse" x1="32" y1="0" x2="0" y2="64">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#f3f3f3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g filter="url(#cornerShadow)">
|
||||
<g transform="matrix(-1.81626,-1.81626,0.460023,-0.460023,180.663,203.387)">
|
||||
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="url(#cornerGradient)"/>
|
||||
</g>
|
||||
<g transform="matrix(1.81626,1.81626,-0.460023,0.460023,4.6259,-132.676)">
|
||||
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="#fdfdfd"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -118,7 +118,7 @@ function CorrespondentsPanel({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="correspondents-panel column">
|
||||
<section className="correspondents-panel">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<h2>Correspondents</h2>
|
||||
|
||||
+919
-112
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,50 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
|
||||
|
||||
const FOLDER_ICON_SRC = '/folder.svg';
|
||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons';
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const DEFAULT_GRID_ICON_SIZE = 96;
|
||||
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
||||
const LIST_ICON_SIZE = 48;
|
||||
|
||||
const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
|
||||
const getPageCount = (doc) =>
|
||||
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
||||
? doc.current_version.metadata.page_count
|
||||
: null;
|
||||
|
||||
const DocumentThumbnailImage = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
alt,
|
||||
maxSize = LIST_ICON_SIZE,
|
||||
}) => {
|
||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||
const thumbnailAsset = useMemo(
|
||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||
[document?.current_version],
|
||||
);
|
||||
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
|
||||
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
|
||||
const assetWidth = Number(primaryMetadata?.width);
|
||||
const assetHeight = Number(primaryMetadata?.height);
|
||||
|
||||
const dimensions = useMemo(() => {
|
||||
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
|
||||
return { width: resolvedMaxSize, height: resolvedMaxSize };
|
||||
}
|
||||
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
|
||||
return {
|
||||
width: Math.max(1, Math.round(assetWidth * scale)),
|
||||
height: Math.max(1, Math.round(assetHeight * scale)),
|
||||
};
|
||||
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
||||
|
||||
const innerStyle = useMemo(
|
||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||
[dimensions.height, dimensions.width],
|
||||
);
|
||||
const url = useMemo(
|
||||
() =>
|
||||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
@@ -17,19 +54,28 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al
|
||||
[document, ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
|
||||
const pageCount = getPageCount(document);
|
||||
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
||||
const innerClasses = ['document-thumbnail-inner'];
|
||||
if (showMultiPageBadge) {
|
||||
innerClasses.push('document-thumbnail-inner--multipage');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="document-thumbnail-wrapper">
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || ''}
|
||||
className="document-thumbnail"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumb-placeholder">DOC</div>
|
||||
)}
|
||||
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || ''}
|
||||
className="document-thumbnail"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<div className="thumb-placeholder">DOC</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -97,6 +143,7 @@ const DocumentsTable = ({
|
||||
);
|
||||
const scrollRef = useRef(null);
|
||||
const isGridView = viewMode === 'grid';
|
||||
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||
const handleSetViewMode = useCallback(
|
||||
(nextMode) => {
|
||||
if (!onViewModeChange) {
|
||||
@@ -216,15 +263,12 @@ const DocumentsTable = ({
|
||||
[isTagDragEvent, onDocumentTagDrop],
|
||||
);
|
||||
|
||||
const handleGridBackgroundClick = useCallback(
|
||||
(event) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
onClearSelection?.();
|
||||
},
|
||||
[onClearSelection],
|
||||
);
|
||||
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
||||
const showListSearchEmptyState =
|
||||
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
||||
const showGridSearchEmptyState =
|
||||
isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading;
|
||||
const showSearchHint = showingSearchResults && rows.length > 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -232,29 +276,7 @@ const DocumentsTable = ({
|
||||
>
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<span key={crumb.id} className="breadcrumb-item">
|
||||
{isLast ? (
|
||||
<span className="breadcrumb-current">{crumb.name}</span>
|
||||
) : (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect(crumb.id);
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</a>
|
||||
)}
|
||||
{!isLast && <span className="breadcrumb-separator">›</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<h2>{currentFolderName}</h2>
|
||||
{showingSearchResults && (
|
||||
<div className="column-subtitle">Search results</div>
|
||||
)}
|
||||
@@ -295,11 +317,20 @@ const DocumentsTable = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showDefaultEmptyState && (
|
||||
<div className="empty-state">
|
||||
Drop files anywhere or onto a folder to upload documents.
|
||||
</div>
|
||||
)}
|
||||
{showGridSearchEmptyState && (
|
||||
<div className="empty-state empty-state--global">
|
||||
No documents match the current filters.
|
||||
</div>
|
||||
)}
|
||||
<div className="column-body">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="documents-scroll"
|
||||
tabIndex={0}
|
||||
onFocus={(event) => {
|
||||
if (event.target === scrollRef.current) {
|
||||
onDocumentListFocus?.();
|
||||
@@ -313,17 +344,23 @@ const DocumentsTable = ({
|
||||
onDocumentListKeyDown(event);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClearSelection?.();
|
||||
}
|
||||
}}
|
||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||
>
|
||||
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
Drop files anywhere or onto a folder to upload documents.
|
||||
</div>
|
||||
) : isGridView ? (
|
||||
{showDefaultEmptyState ? null : isGridView ? (
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
onClick={handleGridBackgroundClick}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClearSelection?.();
|
||||
}
|
||||
}}
|
||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||
>
|
||||
{!showingSearchResults &&
|
||||
subfolders.map((folder) => {
|
||||
@@ -370,10 +407,9 @@ const DocumentsTable = ({
|
||||
}}
|
||||
>
|
||||
<div className="folder-card__icon">
|
||||
<img
|
||||
src={FOLDER_ICON_SRC}
|
||||
alt="Folder"
|
||||
<FolderIcon
|
||||
className="folder-card__icon-svg"
|
||||
size={gridIconSize}
|
||||
/>
|
||||
</div>
|
||||
<div className="folder-card__meta">
|
||||
@@ -399,6 +435,7 @@ const DocumentsTable = ({
|
||||
className={cardClasses.join(' ')}
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
@@ -416,6 +453,7 @@ const DocumentsTable = ({
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||
maxSize={gridIconSize}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div
|
||||
@@ -442,7 +480,6 @@ const DocumentsTable = ({
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -550,23 +587,29 @@ const DocumentsTable = ({
|
||||
>
|
||||
<td className="thumb-cell">
|
||||
<div className="thumb-icon">
|
||||
<img
|
||||
src={FOLDER_ICON_SRC}
|
||||
alt="Folder"
|
||||
<FolderIcon
|
||||
className="thumb-icon__image"
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-list__name-content">
|
||||
<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
|
||||
type="button"
|
||||
className="icon-button ghost doc-list__icon-button"
|
||||
className="icon-button ghost"
|
||||
title="Rename"
|
||||
aria-label={`Rename folder ${folder.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!onFolderRename) return;
|
||||
const nextName = window.prompt('Rename folder', folder.name || '');
|
||||
if (!nextName) {
|
||||
return;
|
||||
@@ -577,28 +620,24 @@ const DocumentsTable = ({
|
||||
}
|
||||
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
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -615,6 +654,7 @@ const DocumentsTable = ({
|
||||
key={doc.id}
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||
draggable
|
||||
@@ -636,30 +676,6 @@ const DocumentsTable = ({
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<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>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
@@ -679,7 +695,6 @@ const DocumentsTable = ({
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -729,31 +744,59 @@ const DocumentsTable = ({
|
||||
</td>
|
||||
<td className="actions">
|
||||
<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 ? (
|
||||
<a
|
||||
className="button-link with-icon"
|
||||
className="icon-button"
|
||||
href={downloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Download"
|
||||
aria-label="Download document"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onAuxClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DownloadIcon className="icon-inline" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="meta">No download</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
className="icon-button danger"
|
||||
title="Delete"
|
||||
aria-label="Delete document"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDocumentDelete?.(doc.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -764,23 +807,18 @@ const DocumentsTable = ({
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
|
||||
{showListSearchEmptyState && (
|
||||
<div className="empty-state">No documents match the current filters.</div>
|
||||
)}
|
||||
{showingSearchResults && rows.length > 0 && (
|
||||
{showSearchHint && (
|
||||
<div className="search-hint">
|
||||
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
|
||||
<div className="empty-state empty-state--global">
|
||||
No documents match the current filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default DocumentsTable;
|
||||
export { DocumentThumbnailImage };
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createAssetView } from '../asset_manager';
|
||||
|
||||
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
|
||||
const raw = Number.isFinite(value) ? value : defaultOrdinal;
|
||||
let next = Math.max(1, Math.floor(raw));
|
||||
if (cardinality && cardinality > 0) {
|
||||
next = Math.min(next, cardinality);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const useAssetNavigator = ({
|
||||
document,
|
||||
assetType,
|
||||
ensureAssetUrl,
|
||||
getAsset,
|
||||
prefetch = 2,
|
||||
defaultOrdinal = 1,
|
||||
}) => {
|
||||
const documentId = document?.id || null;
|
||||
|
||||
const asset = useMemo(() => {
|
||||
if (!document || typeof getAsset !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return getAsset(document, assetType);
|
||||
}, [document, assetType, getAsset]);
|
||||
|
||||
const view = useMemo(() => createAssetView(asset), [asset]);
|
||||
const cardinality = view.getCardinality();
|
||||
|
||||
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
|
||||
|
||||
useEffect(() => {
|
||||
setOrdinalInternal(defaultOrdinal);
|
||||
}, [documentId, assetType, defaultOrdinal]);
|
||||
|
||||
const setOrdinal = useCallback(
|
||||
(next) => {
|
||||
setOrdinalInternal((prev) => {
|
||||
const target = typeof next === 'function' ? next(prev) : next;
|
||||
return clampOrdinalValue(target, cardinality, defaultOrdinal);
|
||||
});
|
||||
},
|
||||
[cardinality, defaultOrdinal],
|
||||
);
|
||||
|
||||
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
|
||||
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
|
||||
|
||||
const objects = view.getObjects();
|
||||
const currentObject = view.getObject(ordinal);
|
||||
const currentUrl = currentObject?.url || view.getPrimaryUrl();
|
||||
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
|
||||
|
||||
const canGoPrev = ordinal > 1;
|
||||
const canGoNext = cardinality ? ordinal < cardinality : true;
|
||||
|
||||
const ordinalsNeedingLoad = useMemo(() => {
|
||||
const missing = [];
|
||||
if (!asset) {
|
||||
return missing;
|
||||
}
|
||||
const maxOrdinal = cardinality && cardinality > 0
|
||||
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
|
||||
: ordinal + Math.max(1, prefetch) - 1;
|
||||
|
||||
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
|
||||
const object = view.getObject(ord);
|
||||
if (!object?.url) {
|
||||
missing.push(ord);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}, [asset, view, ordinal, prefetch, cardinality]);
|
||||
|
||||
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
|
||||
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
|
||||
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId || !asset || !ensureAssetUrl) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!fetchStart || !fetchLimit) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
ensureAssetUrl(documentId, asset, {
|
||||
start: fetchStart,
|
||||
limit: fetchLimit,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
|
||||
|
||||
return {
|
||||
document,
|
||||
documentId,
|
||||
asset,
|
||||
assetType,
|
||||
ordinal,
|
||||
setOrdinal,
|
||||
goPrev,
|
||||
goNext,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
cardinality,
|
||||
currentObject,
|
||||
currentUrl,
|
||||
currentMetadata,
|
||||
objects,
|
||||
isLoading: loading,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAssetNavigator;
|
||||
+507
-492
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { DownloadIcon } from '../ui/icons';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
|
||||
const PreviewWorkspace = ({
|
||||
document,
|
||||
previewEntry,
|
||||
resolveApiPath,
|
||||
onClose,
|
||||
onRegenerateThumbnails,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = document.title || document.original_name || 'Document';
|
||||
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
|
||||
const downloadHref = document.current_version?.download_path
|
||||
? resolveApiPath(document.current_version.download_path)
|
||||
: null;
|
||||
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
|
||||
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
|
||||
const metadata =
|
||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
||||
|
||||
return (
|
||||
<section className="preview-workspace">
|
||||
<header className="preview-workspace__header">
|
||||
<div className="preview-workspace__meta">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onClose(document.folder_id ?? 'root')}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<span className="meta">
|
||||
{document.content_type || mime}
|
||||
{sizeLabel ? ` · ${sizeLabel}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-workspace__actions">
|
||||
<a
|
||||
className="button-link with-icon"
|
||||
href={downloadHref || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-disabled={!downloadHref}
|
||||
onClick={(event) => {
|
||||
if (!downloadHref) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="icon-inline" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => onRegenerateThumbnails(document.id)}
|
||||
>
|
||||
Re-run analysis
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="preview-workspace__body">
|
||||
{!previewEntry?.url ? (
|
||||
<div className="preview-workspace__message">Loading preview…</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={previewEntry.url}
|
||||
title={`Preview of ${title}`}
|
||||
className="preview-workspace__object"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{metadata && (
|
||||
<section className="preview-workspace__metadata">
|
||||
<h3>Metadata</h3>
|
||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewWorkspace;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAppShell } from '../appShellContext';
|
||||
import PreviewWorkspace from '../preview/PreviewWorkspace';
|
||||
|
||||
const DocumentViewerRoute = () => {
|
||||
const {
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
ensurePreviewData,
|
||||
notifyApiError,
|
||||
resolveApiPath,
|
||||
} = useAppShell();
|
||||
const { documentId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId) {
|
||||
navigate('/documents', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
await ensurePreviewData(documentId);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
notifyApiError(error, 'Failed to open document preview.');
|
||||
navigate('/documents', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
hydrate();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
|
||||
|
||||
const isReady =
|
||||
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<main className="preview-main">
|
||||
<div className="preview-workspace__message">Loading preview…</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="preview-main">
|
||||
<PreviewWorkspace
|
||||
document={previewWorkspaceDocument}
|
||||
previewEntry={previewWorkspaceEntry}
|
||||
resolveApiPath={resolveApiPath}
|
||||
onClose={closeDocumentPreview}
|
||||
onRegenerateThumbnails={handleThumbnailRegeneration}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentViewerRoute;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ChevronIcon, TrashIcon, EditIcon } from '../ui/icons';
|
||||
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons';
|
||||
|
||||
const FOLDER_ICON_SRC = '/folder.svg';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
|
||||
const FolderNode = ({
|
||||
@@ -68,7 +67,7 @@ const FolderNode = ({
|
||||
</span>
|
||||
)}
|
||||
<span className="name">
|
||||
<img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" />
|
||||
<FolderIcon className="folder-icon-image" size={16} />
|
||||
{node.name}
|
||||
</span>
|
||||
{node.id !== 'root' && (
|
||||
@@ -137,6 +136,18 @@ const Sidebar = ({
|
||||
correspondents = [],
|
||||
activeCorrespondentIds = [],
|
||||
onToggleCorrespondentFilter,
|
||||
onManageTags,
|
||||
onManageCorrespondents,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
onSearchClear,
|
||||
isFilterActive,
|
||||
appStatus,
|
||||
loading,
|
||||
previewActive,
|
||||
onLogout,
|
||||
status,
|
||||
}) => {
|
||||
const sortedCorrespondents = useMemo(
|
||||
() =>
|
||||
@@ -151,6 +162,27 @@ const Sidebar = ({
|
||||
);
|
||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||
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(
|
||||
(ids, depth) =>
|
||||
@@ -194,9 +226,39 @@ const Sidebar = ({
|
||||
);
|
||||
|
||||
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 (
|
||||
<aside className="sidebar column">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__meta">
|
||||
<h1 className="sidebar__title">Papercrate</h1>
|
||||
<span className="sidebar__hint">{hintText}</span>
|
||||
{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>
|
||||
)}
|
||||
{status && (
|
||||
<div className="sidebar__status">
|
||||
<div className={`status-banner ${status.variant}`}>{status.message}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-section sidebar-section--folders">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Folders</h3>
|
||||
@@ -207,8 +269,14 @@ const Sidebar = ({
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Tags</h3>
|
||||
<span className="meta">{tags.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-section__title"
|
||||
onClick={handleManageTags}
|
||||
>
|
||||
<h3>Tags</h3>
|
||||
<span className="meta">{tags.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-tag-cloud${
|
||||
@@ -216,85 +284,87 @@ const Sidebar = ({
|
||||
}`}
|
||||
role="list"
|
||||
>
|
||||
{tags.length ? (
|
||||
tags.map((tag) => {
|
||||
const isActive = activeTagSet.has(tag.id);
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={className}
|
||||
style={style || undefined}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
aria-pressed={isActive}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || null,
|
||||
});
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="meta">No tags yet</span>
|
||||
)}
|
||||
{tags.map((tag) => {
|
||||
const isActive = activeTagSet.has(tag.id);
|
||||
const style = getTagColorStyle(tag.color);
|
||||
const className = `sidebar-tag-pill${isActive ? ' active' : ''}`;
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={className}
|
||||
style={style || undefined}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
aria-pressed={isActive}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color || null,
|
||||
});
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section__header">
|
||||
<h3>Correspondents</h3>
|
||||
<span className="meta">{correspondents.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-section__title"
|
||||
onClick={handleManageCorrespondents}
|
||||
>
|
||||
<h3>Correspondents</h3>
|
||||
<span className="meta">{correspondents.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul className="sidebar-correspondent-list">
|
||||
{sortedCorrespondents.length ? (
|
||||
sortedCorrespondents.map((correspondent) => {
|
||||
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||
const label = correspondent.name || 'Unnamed';
|
||||
const handleSelect = () => {
|
||||
const nextId = isActive ? null : correspondent.id;
|
||||
onToggleCorrespondentFilter?.(nextId);
|
||||
};
|
||||
return (
|
||||
<li key={correspondent.id}>
|
||||
<span
|
||||
className={className}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<li className="meta">No correspondents yet</li>
|
||||
)}
|
||||
{sortedCorrespondents.map((correspondent) => {
|
||||
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||
const label = correspondent.name || 'Unnamed';
|
||||
const handleSelect = () => {
|
||||
const nextId = isActive ? null : correspondent.id;
|
||||
onToggleCorrespondentFilter?.(nextId);
|
||||
};
|
||||
return (
|
||||
<li key={correspondent.id}>
|
||||
<span
|
||||
className={className}
|
||||
role="button"
|
||||
onClick={handleSelect}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="sidebar__footer">
|
||||
<button className="secondary" type="button" onClick={handleLogoutClick}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-column: 2 / -1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background-color: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.skeuo-header {
|
||||
@@ -21,7 +21,6 @@
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
background-color: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.skeuo-header__meta {
|
||||
@@ -158,7 +157,7 @@
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border-radius: 1px;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
@@ -188,72 +187,63 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf {
|
||||
.skeuo-card__nav {
|
||||
position: absolute;
|
||||
top: 2.25rem;
|
||||
right: 1rem;
|
||||
bottom: calc(3em * 0.707 * var(--nav-scale));
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
|
||||
transform-origin: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-end;
|
||||
max-height: calc(100% - 4rem);
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
z-index: 100000;
|
||||
gap: 2rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag.is-inactive {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag.is-active {
|
||||
.skeuo-item__card:hover .skeuo-card__nav {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.skeuo-tag-add {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: var(--fg);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
.skeuo-card__nav-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.3em;
|
||||
height: 3.3em;
|
||||
padding: 0.36em;
|
||||
border-radius: 3.3em;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin-top: 0.4rem;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag-add:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
transform: translateY(-1px);
|
||||
.skeuo-card__nav-button:hover:not([disabled]) {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.skeuo-tag-add:active {
|
||||
transform: translateY(0);
|
||||
.skeuo-card__nav-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.skeuo-tag-add:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
.skeuo-card__nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent, #2684ff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
body.skeuo-cursor-remove,
|
||||
body.skeuo-cursor-remove * {
|
||||
cursor: not-allowed !important;
|
||||
@@ -271,6 +261,7 @@ body.skeuo-cursor-remove * {
|
||||
justify-content: center;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
||||
overflow: hidden;
|
||||
--nav-scale: 1;
|
||||
}
|
||||
|
||||
.skeuo-item__card--empty {
|
||||
|
||||
+151
-233
@@ -6,8 +6,10 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { resolveDocumentAssetUrl } from './asset_manager';
|
||||
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
|
||||
import { getReadableTextColor } from './utils/colors';
|
||||
import './skeuomorphic_ws.css';
|
||||
|
||||
const ITEM_WIDTH = 220;
|
||||
@@ -31,7 +33,6 @@ const ZOOM_MIN_SCALE = 1.05;
|
||||
const ZOOM_MAX_SCALE = 5;
|
||||
const TAG_REMOVE_DISTANCE = 160;
|
||||
|
||||
const DRAG_PREVIEW_KEY = Symbol('dragPreview');
|
||||
const DEBUG_DRAG = false;
|
||||
const DEBUG_FOCUS = true;
|
||||
const DEBUG_DROP = true;
|
||||
@@ -44,6 +45,107 @@ const resolveTagKey = (tag) => {
|
||||
return key != null ? String(key) : null;
|
||||
};
|
||||
|
||||
const SkeuoPreviewCard = ({
|
||||
doc,
|
||||
title,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
navScale = 1,
|
||||
prefetch = 3,
|
||||
}) => {
|
||||
const navigator = useAssetNavigator({
|
||||
document: doc,
|
||||
assetType: 'preview',
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
prefetch,
|
||||
});
|
||||
|
||||
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
|
||||
const hasPreview = Boolean(currentUrl);
|
||||
const cardClasses = ['skeuo-item__card'];
|
||||
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
||||
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
|
||||
const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]);
|
||||
|
||||
return (
|
||||
<div className={cardClasses.join(' ')}>
|
||||
{hasPreview ? (
|
||||
<img src={currentUrl} alt={title} />
|
||||
) : (
|
||||
<div className="skeuo-item__empty">
|
||||
<div className="skeuo-item__placeholder">DOC</div>
|
||||
<div className="skeuo-item__title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showNav ? (
|
||||
<div className="skeuo-card__nav" style={navStyle}>
|
||||
<button
|
||||
type="button"
|
||||
className="skeuo-card__nav-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.goPrev();
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous preview"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="skeuo-card__nav-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.goNext();
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next preview"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const generateInitialLayout = (
|
||||
entries,
|
||||
{
|
||||
@@ -211,6 +313,15 @@ const useDocumentDrag = ({
|
||||
if (!state || state.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const capturedTarget = state.capturedTarget;
|
||||
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.releasePointerCapture(pointerId);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
setDraggingId((current) => (current === state.docId ? null : current));
|
||||
syncLayoutSnapshot();
|
||||
@@ -247,6 +358,15 @@ const useDocumentDrag = ({
|
||||
}
|
||||
|
||||
bringToFront(docId);
|
||||
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
||||
try {
|
||||
capturedTarget.setPointerCapture(event.pointerId);
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}
|
||||
dragStateRef.current = {
|
||||
docId,
|
||||
pointerId: event.pointerId,
|
||||
@@ -260,6 +380,7 @@ const useDocumentDrag = ({
|
||||
width: docWidth,
|
||||
height: docHeight,
|
||||
scale: baseScale,
|
||||
capturedTarget,
|
||||
};
|
||||
setDraggingId(docId);
|
||||
},
|
||||
@@ -432,20 +553,17 @@ const SkeuomorphicWorkspace = ({
|
||||
onRefresh,
|
||||
onDocumentOpen,
|
||||
resolveThumbnailUrl,
|
||||
availableTags = [],
|
||||
onCreateTag = null,
|
||||
onAssignTagToDocument = null,
|
||||
onRemoveTagFromDocument = null,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
prepareTagPayload = null,
|
||||
activeTagIds = [],
|
||||
}) => {
|
||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||
const showingSearchResults = searchResults !== null;
|
||||
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const tagShelfRef = useRef(null);
|
||||
const layoutRef = useRef(new Map());
|
||||
const itemRefs = useRef(new Map());
|
||||
const zCounterRef = useRef(10);
|
||||
@@ -453,17 +571,27 @@ const SkeuomorphicWorkspace = ({
|
||||
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
||||
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
const [tagShelfWidth, setTagShelfWidth] = useState(0);
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [zoomedId, setZoomedId] = useState(null);
|
||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
||||
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
||||
const [activeShelfTagId, setActiveShelfTagId] = useState(null);
|
||||
const draggingTagRef = useRef(null);
|
||||
const pendingDocTagDragRef = useRef(null);
|
||||
const docSizeMapRef = useRef(new Map());
|
||||
const removalCursorActiveRef = useRef(false);
|
||||
const activeTagSet = useMemo(() => {
|
||||
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
const set = new Set();
|
||||
activeTagIds.forEach((id) => {
|
||||
if (id != null) {
|
||||
set.add(String(id));
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}, [activeTagIds]);
|
||||
|
||||
const resolvePreviewAsset = useCallback(
|
||||
(doc) => {
|
||||
@@ -477,8 +605,10 @@ const SkeuomorphicWorkspace = ({
|
||||
(doc) => {
|
||||
if (!doc) return null;
|
||||
const asset = resolvePreviewAsset(doc);
|
||||
const width = asset?.metadata?.width;
|
||||
const height = asset?.metadata?.height;
|
||||
const view = createAssetView(asset);
|
||||
const metadata = view.getPrimaryMetadata() || {};
|
||||
const width = metadata?.width;
|
||||
const height = metadata?.height;
|
||||
if (typeof width === 'number' && typeof height === 'number') {
|
||||
return { width, height };
|
||||
}
|
||||
@@ -487,17 +617,6 @@ const SkeuomorphicWorkspace = ({
|
||||
[resolvePreviewAsset],
|
||||
);
|
||||
|
||||
const resolvePreviewUrl = useCallback(
|
||||
(doc) => {
|
||||
if (!doc) return null;
|
||||
return resolveDocumentAssetUrl(doc, 'preview', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
},
|
||||
[ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ensureAssetUrl) {
|
||||
return;
|
||||
@@ -591,72 +710,11 @@ const SkeuomorphicWorkspace = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleShelfTagDragStart = useCallback((event, tag) => {
|
||||
if (!tag) return;
|
||||
try {
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
const payload = JSON.stringify({ id: tag.id, label: tag.label });
|
||||
event.dataTransfer.setData('application/x-papercrate-tag', payload);
|
||||
event.dataTransfer.setData('text/papercrate-tag', payload);
|
||||
event.dataTransfer.setData('text/plain', tag.label || 'Tag');
|
||||
} catch (error) {
|
||||
console.warn('Failed to initiate tag drag', error);
|
||||
}
|
||||
const node = event.currentTarget;
|
||||
const hideNode = () => {
|
||||
if (node instanceof HTMLElement) {
|
||||
node.classList.add('is-drag-hidden');
|
||||
}
|
||||
};
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node[DRAG_PREVIEW_KEY]) {
|
||||
cleanupPreview(node[DRAG_PREVIEW_KEY]);
|
||||
delete node[DRAG_PREVIEW_KEY];
|
||||
}
|
||||
const preview = createDragPreview(node, event.clientX, event.clientY);
|
||||
if (preview && event.dataTransfer) {
|
||||
try {
|
||||
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
|
||||
node[DRAG_PREVIEW_KEY] = preview.clone;
|
||||
} catch (
|
||||
// eslint-disable-next-line no-empty
|
||||
error
|
||||
) {}
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(hideNode);
|
||||
} else {
|
||||
setTimeout(hideNode, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTagDragEnd = useCallback(() => {
|
||||
updateRemovalCursor(false);
|
||||
setTagDropTargetId(null);
|
||||
}, [updateRemovalCursor]);
|
||||
|
||||
const handleShelfTagDragEndWithReset = useCallback((event) => {
|
||||
if (event?.currentTarget instanceof HTMLElement) {
|
||||
const target = event.currentTarget;
|
||||
const showNode = () => {
|
||||
if (target instanceof HTMLElement) {
|
||||
target.classList.remove('is-drag-hidden');
|
||||
const stored = target[DRAG_PREVIEW_KEY];
|
||||
cleanupPreview(stored);
|
||||
delete target[DRAG_PREVIEW_KEY];
|
||||
}
|
||||
};
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(showNode);
|
||||
} else {
|
||||
setTimeout(showNode, 0);
|
||||
}
|
||||
}
|
||||
handleTagDragEnd();
|
||||
queueFocusCanvas();
|
||||
}, [handleTagDragEnd, queueFocusCanvas]);
|
||||
|
||||
const ensureDocumentSize = useCallback((doc) => {
|
||||
const key = resolveSizeKey(doc);
|
||||
const cache = docSizeMapRef.current.get(key);
|
||||
@@ -721,17 +779,6 @@ const SkeuomorphicWorkspace = ({
|
||||
docSizeMapRef.current = new Map();
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeShelfTagId) {
|
||||
return;
|
||||
}
|
||||
const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId);
|
||||
if (!stillExists) {
|
||||
setActiveShelfTagId(null);
|
||||
}
|
||||
}, [activeShelfTagId, availableTags]);
|
||||
|
||||
|
||||
const resolveZoomMetrics = useCallback(
|
||||
(doc, cardWidth, cardHeight) => {
|
||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||
@@ -821,30 +868,6 @@ const SkeuomorphicWorkspace = ({
|
||||
[resolvePreviewDimensions],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shelfNode = tagShelfRef.current;
|
||||
if (!shelfNode || !availableTags.length) {
|
||||
setTagShelfWidth(0);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
const rect = shelfNode.getBoundingClientRect();
|
||||
setTagShelfWidth(Math.ceil(rect.width));
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(shelfNode);
|
||||
return () => observer.disconnect();
|
||||
}, [availableTags.length]);
|
||||
|
||||
const syncLayoutSnapshot = useCallback(() => {
|
||||
setLayoutSnapshot(new Map(layoutRef.current));
|
||||
}, []);
|
||||
@@ -950,7 +973,7 @@ const SkeuomorphicWorkspace = ({
|
||||
startZ: maxZ,
|
||||
rotationRange: ROTATION_RANGE,
|
||||
minSpacing: 48,
|
||||
shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
|
||||
shelfWidth: 0,
|
||||
},
|
||||
);
|
||||
generatedLayout.forEach((entry, docId) => {
|
||||
@@ -971,7 +994,6 @@ const SkeuomorphicWorkspace = ({
|
||||
canvasSize.height,
|
||||
ensureDocumentSize,
|
||||
syncLayoutSnapshot,
|
||||
tagShelfWidth,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1150,7 +1172,7 @@ const SkeuomorphicWorkspace = ({
|
||||
|
||||
setPendingTagDocId(doc.id);
|
||||
try {
|
||||
await onAssignTagToDocument({ documentId: doc.id, tagId });
|
||||
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
|
||||
markActiveTagDropHandled(tagId, sourceDocId);
|
||||
if (DEBUG_DROP) {
|
||||
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
|
||||
@@ -1467,29 +1489,11 @@ const SkeuomorphicWorkspace = ({
|
||||
[queueFocusCanvas, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor],
|
||||
);
|
||||
|
||||
const renderBreadcrumbs = () => {
|
||||
if (!breadcrumbs.length) return null;
|
||||
return (
|
||||
<nav className="skeuo-breadcrumbs" aria-label="Folder breadcrumbs">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<span key={crumb.id} className={`skeuo-crumb${isLast ? ' is-current' : ''}`}>
|
||||
{crumb.name}
|
||||
{!isLast && <span className="skeuo-crumb-separator">›</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="skeuo-shell">
|
||||
<header className="skeuo-header">
|
||||
<div className="skeuo-header__meta">
|
||||
<h2>{currentFolderName}</h2>
|
||||
{renderBreadcrumbs()}
|
||||
{showingSearchResults && <span className="meta">Showing search results</span>}
|
||||
</div>
|
||||
<div className="skeuo-header__actions">
|
||||
@@ -1504,7 +1508,6 @@ const SkeuomorphicWorkspace = ({
|
||||
<div
|
||||
className="skeuo-canvas"
|
||||
ref={containerRef}
|
||||
data-active-tag={activeShelfTagId || undefined}
|
||||
onDragOver={handleCanvasDragOver}
|
||||
onDragLeave={handleCanvasDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
@@ -1562,9 +1565,6 @@ const SkeuomorphicWorkspace = ({
|
||||
const totalScale = zoomScale > 0 ? zoomScale : 1;
|
||||
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
|
||||
const tagsStyle = { '--tag-scale': inverseTagScale };
|
||||
const previewUrl = resolvePreviewUrl(doc);
|
||||
const imageUrl = previewUrl;
|
||||
const hasPreview = Boolean(imageUrl);
|
||||
const title = doc.title || doc.original_name || 'Document';
|
||||
const dragging = draggingId === doc.id;
|
||||
const tags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
@@ -1572,7 +1572,7 @@ const SkeuomorphicWorkspace = ({
|
||||
.map((tag) => resolveTagKey(tag))
|
||||
.filter(Boolean);
|
||||
const matchesFilter =
|
||||
!activeShelfTagId || docTagKeys.includes(activeShelfTagId);
|
||||
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
||||
const dropActive = tagDropTargetId === doc.id;
|
||||
const dropPending = pendingTagDocId === doc.id;
|
||||
const itemClasses = ['skeuo-item'];
|
||||
@@ -1581,8 +1581,6 @@ const SkeuomorphicWorkspace = ({
|
||||
if (dropActive) itemClasses.push('is-tag-target');
|
||||
if (dropPending) itemClasses.push('is-tag-pending');
|
||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||
const cardClasses = ['skeuo-item__card'];
|
||||
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
||||
const docTagTokens = docTagKeys.join(' ');
|
||||
return (
|
||||
<div
|
||||
@@ -1618,18 +1616,13 @@ const SkeuomorphicWorkspace = ({
|
||||
}}
|
||||
>
|
||||
<div className="skeuo-item__body" style={bodyStyle}>
|
||||
<div className={cardClasses.join(' ')}>
|
||||
{hasPreview ? (
|
||||
<img src={imageUrl} alt={title} />
|
||||
) : (
|
||||
<div className="skeuo-item__empty">
|
||||
<div className="skeuo-item__placeholder">DOC</div>
|
||||
<div className="skeuo-item__title" title={title}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkeuoPreviewCard
|
||||
doc={doc}
|
||||
title={title}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
navScale={inverseTagScale}
|
||||
/>
|
||||
{tags.length > 0 && (
|
||||
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
|
||||
{tags.map((tag) => {
|
||||
@@ -1674,81 +1667,6 @@ const SkeuomorphicWorkspace = ({
|
||||
);
|
||||
})
|
||||
)}
|
||||
{(availableTags.length > 0 || onCreateTag) && (
|
||||
<div
|
||||
className="skeuo-tag-shelf"
|
||||
aria-label="Available tags"
|
||||
ref={tagShelfRef}
|
||||
>
|
||||
{availableTags.map((tag) => {
|
||||
const colorValue = normalizeColor(tag.color);
|
||||
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
|
||||
const tagStyle = colorValue
|
||||
? { backgroundColor: colorValue, color: foreground }
|
||||
: undefined;
|
||||
const tagKey = resolveTagKey(tag);
|
||||
const isSelected = activeShelfTagId === tagKey;
|
||||
const shelfTagClasses = ['skeuo-tag'];
|
||||
if (isSelected) {
|
||||
shelfTagClasses.push('is-active');
|
||||
} else if (activeShelfTagId) {
|
||||
shelfTagClasses.push('is-inactive');
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tag.id || tag.label}
|
||||
className={shelfTagClasses.join(' ')}
|
||||
style={tagStyle}
|
||||
title={tag.label}
|
||||
draggable
|
||||
data-tag-id={tagKey || undefined}
|
||||
onDragStart={(event) => handleShelfTagDragStart(event, tag)}
|
||||
onDragEnd={handleShelfTagDragEndWithReset}
|
||||
role="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveShelfTagId((current) => (current === tagKey ? null : tagKey));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setActiveShelfTagId((current) => (current === tagKey ? null : tagKey));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{tag.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{typeof onCreateTag === 'function' && (
|
||||
<button
|
||||
type="button"
|
||||
className="skeuo-tag-add"
|
||||
aria-label="Add tag"
|
||||
onClick={async () => {
|
||||
const labelInput = window.prompt('New tag name?');
|
||||
const label = labelInput ? labelInput.trim() : '';
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload =
|
||||
typeof prepareTagPayload === 'function'
|
||||
? prepareTagPayload({ label })
|
||||
: { label, color: generateRandomTagColor() };
|
||||
await onCreateTag(payload);
|
||||
setActiveShelfTagId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to create tag', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+639
-341
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,22 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
|
||||
|
||||
function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
||||
function TagsPanel({
|
||||
tags,
|
||||
onRefresh,
|
||||
onCreateTag,
|
||||
onUpdateTag,
|
||||
onDeleteTag,
|
||||
onNotify,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [draftLabel, setDraftLabel] = useState('');
|
||||
const [draftColor, setDraftColor] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [createLabel, setCreateLabel] = useState('');
|
||||
const [createColor, setCreateColor] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const startEdit = useCallback((tag) => {
|
||||
setEditingId(tag.id);
|
||||
@@ -98,19 +108,87 @@ function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
|
||||
[onDeleteTag, editingId, cancelEdit, onNotify],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (typeof onCreateTag !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedLabel = createLabel.trim();
|
||||
if (!trimmedLabel) {
|
||||
onNotify?.('Tag label cannot be empty.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedColor = createColor.trim();
|
||||
const colorPattern = /^#([0-9a-fA-F]{6})$/;
|
||||
if (trimmedColor && !colorPattern.test(trimmedColor)) {
|
||||
onNotify?.('Colors must use the #RRGGBB format.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreateTag({
|
||||
label: trimmedLabel,
|
||||
color: trimmedColor ? trimmedColor : null,
|
||||
});
|
||||
setCreateLabel('');
|
||||
setCreateColor('');
|
||||
} catch (createError) {
|
||||
const message = createError?.message || 'Failed to create tag.';
|
||||
onNotify?.(message, 'error');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
},
|
||||
[createLabel, createColor, onCreateTag, onNotify],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="tags-panel column">
|
||||
<section className="tags-panel">
|
||||
<div className="column-header">
|
||||
<div className="column-header__titles">
|
||||
<h2>Tags</h2>
|
||||
<div className="column-subtitle">{tags.length} total</div>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="header-actions tags-actions">
|
||||
<form className="tags-actions__form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New tag label"
|
||||
value={createLabel}
|
||||
onChange={(event) => setCreateLabel(event.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
className="tags-table__color-picker"
|
||||
value={createColor || '#3366ff'}
|
||||
onChange={(event) => setCreateColor(event.target.value)}
|
||||
disabled={creating}
|
||||
aria-label="Tag color (optional)"
|
||||
/>
|
||||
{createColor && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setCreateColor('')}
|
||||
disabled={creating}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={creating || !createLabel.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={saving || Boolean(deletingId)}
|
||||
disabled={saving || creating || Boolean(deletingId)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import {
|
||||
IconChevronRight as TablerChevronRight,
|
||||
IconDownload as TablerDownload,
|
||||
IconFolderFilled,
|
||||
IconPencil,
|
||||
IconTagFilled,
|
||||
IconUserFilled,
|
||||
IconTrash,
|
||||
IconLayoutList,
|
||||
IconLayoutGrid,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconChevronsRight,
|
||||
IconAnalyze,
|
||||
IconWindowMaximize,
|
||||
IconTextScan2,
|
||||
} from '@tabler/icons-react';
|
||||
import FolderSvg from '../assets/folder.svg';
|
||||
|
||||
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
||||
|
||||
@@ -39,14 +45,21 @@ export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
|
||||
/>
|
||||
);
|
||||
|
||||
export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||
<IconFolderFilled
|
||||
className={composeClassName('icon icon--fill', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
export const FolderIcon = ({ className, size = 16, title, ...rest }) => {
|
||||
const dimensionProps = typeof size === 'number' ? { width: size, height: size } : {};
|
||||
|
||||
return (
|
||||
<FolderSvg
|
||||
className={composeClassName('folder-icon', className)}
|
||||
{...dimensionProps}
|
||||
role={title ? 'img' : 'presentation'}
|
||||
aria-hidden={title ? undefined : true}
|
||||
focusable="false"
|
||||
title={title}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||
<IconTagFilled
|
||||
@@ -93,6 +106,51 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
|
||||
/>
|
||||
);
|
||||
|
||||
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconArrowLeft
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconArrowRight
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconChevronsRight
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconAnalyze
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconWindowMaximize
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export default {
|
||||
ChevronIcon,
|
||||
TrashIcon,
|
||||
@@ -103,4 +161,16 @@ export default {
|
||||
DownloadIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
ChevronsRightIcon,
|
||||
AnalyzeIcon,
|
||||
WindowMaximizeIcon,
|
||||
};
|
||||
|
||||
export const TextScanIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconTextScan2
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,16 @@ module.exports = {
|
||||
test: /\.css$/i,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
use: ['@svgr/webpack'],
|
||||
},
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
type: 'asset/resource',
|
||||
issuer: { not: [/\.[jt]sx?$/] },
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# tmux session for Papercrate dev stack
|
||||
new-session -d -s papercrate -n docker -c . 'docker compose up'
|
||||
|
||||
new-window -t papercrate:1 -n frontend -c ./frontend 'npm run dev'
|
||||
|
||||
new-window -t papercrate:2 -n backend -c ./backend 'cargo run --bin backend'
|
||||
|
||||
new-window -t papercrate:3 -n worker -c ./backend 'cargo run --bin worker'
|
||||
|
||||
select-window -t papercrate:0
|
||||
attach-session -t papercrate
|
||||
@@ -1,26 +0,0 @@
|
||||
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: 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]
|
||||
Reference in New Issue
Block a user