This commit is contained in:
2025-11-05 23:01:42 +01:00
parent c193b30c8e
commit 911051e75d
11 changed files with 275 additions and 68 deletions
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at;
DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order;
DROP COLLATION IF EXISTS unicode_ci;
@@ -0,0 +1,37 @@
CREATE COLLATION IF NOT EXISTS unicode_ci
(provider = icu, locale = 'und-u-ks-level2');
CREATE INDEX idx_documents_tenant_folder_title_order
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_issued_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
issued_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_created_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
created_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_tenant_folder_updated_at
ON tenant.documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
updated_at,
title COLLATE "unicode_ci"
)
WHERE deleted_at IS NULL;
+2 -1
View File
@@ -37,7 +37,8 @@ pub fn create_api_token(
expires_at: Option<NaiveDateTime>, expires_at: Option<NaiveDateTime>,
capability_set_id: Uuid, capability_set_id: Uuid,
) -> Result<IssuedApiToken, AppError> { ) -> Result<IssuedApiToken, AppError> {
let capability_set = validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?; let capability_set =
validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
let raw_secret = generate_secret()?; let raw_secret = generate_secret()?;
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
+3
View File
@@ -2,5 +2,8 @@ pub mod asset;
pub mod correspondents; pub mod correspondents;
pub mod folders; pub mod folders;
pub mod metadata; pub mod metadata;
pub mod ordering;
pub mod search; pub mod search;
pub mod tags; pub mod tags;
pub use ordering::{DocumentSortField, SortDirection};
+58
View File
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const UNICODE_COLLATION_NAME: &str = "unicode_ci";
pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2";
const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC";
const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC";
const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST";
const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST";
const CREATED_AT_ASC: &str = "created_at ASC";
const CREATED_AT_DESC: &str = "created_at DESC";
const UPDATED_AT_ASC: &str = "updated_at ASC";
const UPDATED_AT_DESC: &str = "updated_at DESC";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DocumentSortField {
Title,
IssuedAt,
CreatedAt,
UpdatedAt,
}
impl Default for DocumentSortField {
fn default() -> Self {
DocumentSortField::Title
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
impl Default for SortDirection {
fn default() -> Self {
SortDirection::Asc
}
}
pub fn ordering_clauses(
field: DocumentSortField,
direction: SortDirection,
) -> (&'static str, Option<&'static str>) {
match (field, direction) {
(DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None),
(DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None),
(DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)),
(DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)),
}
}
+29 -19
View File
@@ -1,14 +1,11 @@
use std::{ use std::{collections::HashSet, time::Duration};
collections::{HashMap, HashSet},
time::Duration,
};
use axum::extract::{Json, Multipart, Path, Query, State}; use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists; use diesel::dsl::{exists, sql};
use diesel::{prelude::*, result::DatabaseErrorKind, select, OptionalExtension}; use diesel::{prelude::*, result::DatabaseErrorKind, select, sql_types::Text, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -31,6 +28,7 @@ use crate::documents::{
}, },
folders::ensure_folder_exists_on_conn, folders::ensure_folder_exists_on_conn,
metadata::merge_document_metadata, metadata::merge_document_metadata,
ordering::{ordering_clauses, DocumentSortField, SortDirection},
search::quickwit_search, search::quickwit_search,
tags::{assign_tags as assign_tags_to_document, load_tags_for_documents}, tags::{assign_tags as assign_tags_to_document, load_tags_for_documents},
}; };
@@ -72,6 +70,12 @@ pub struct DocumentListQuery {
#[serde(default = "default_document_status_filter")] #[serde(default = "default_document_status_filter")]
#[schema(default = "active")] #[schema(default = "active")]
pub status: DocumentStatusFilter, pub status: DocumentStatusFilter,
#[serde(default)]
#[schema(default = "title")]
pub sort: DocumentSortField,
#[serde(default)]
#[schema(default = "asc")]
pub dir: SortDirection,
} }
fn default_document_status_filter() -> DocumentStatusFilter { fn default_document_status_filter() -> DocumentStatusFilter {
@@ -376,7 +380,11 @@ pub async fn list_documents(
tags, tags,
correspondents, correspondents,
status, status,
sort,
dir,
} = params; } = params;
let sort_field = sort;
let sort_direction = dir;
let mut docs_query = documents::table let mut docs_query = documents::table
.filter(documents::tenant_id.eq(tenant_id)) .filter(documents::tenant_id.eq(tenant_id))
@@ -554,6 +562,12 @@ pub async fn list_documents(
docs_query = docs_query.filter(documents::id.eq_any(ids_vec)); docs_query = docs_query.filter(documents::id.eq_any(ids_vec));
} }
let (primary_sql, secondary_sql) = ordering_clauses(sort_field, sort_direction);
docs_query = docs_query.order(sql::<Text>(primary_sql));
if let Some(second) = secondary_sql {
docs_query = docs_query.then_order_by(sql::<Text>(second));
}
let docs: Vec<Document> = if let Some(order_ids) = quickwit_order.as_ref() { let docs: Vec<Document> = if let Some(order_ids) = quickwit_order.as_ref() {
let relevant_ids: Vec<Uuid> = if let Some(filter_set) = filter_ids.as_ref() { let relevant_ids: Vec<Uuid> = if let Some(filter_set) = filter_ids.as_ref() {
order_ids order_ids
@@ -569,28 +583,24 @@ pub async fn list_documents(
return Ok(Json(vec![])); return Ok(Json(vec![]));
} }
let fetched: Vec<Document> = docs_query.load(&mut conn)?; let mut fetched: Vec<Document> = docs_query.load(&mut conn)?;
let mut by_id: HashMap<Uuid, Document> = let mut ordered = Vec::with_capacity(fetched.len());
fetched.into_iter().map(|doc| (doc.id, doc)).collect();
let mut ordered = Vec::with_capacity(by_id.len());
for id in relevant_ids { for id in relevant_ids {
if let Some(doc) = by_id.remove(&id) { if let Some(pos) = fetched.iter().position(|doc| doc.id == id) {
ordered.push(doc); ordered.push(fetched.remove(pos));
} }
} }
if !by_id.is_empty() { // Any documents not referenced by the search results remain in the
let mut remaining: Vec<Document> = by_id.into_values().collect(); // order supplied by the database (title ASC or the chosen sorting).
remaining.sort_by(|a, b| b.created_at.cmp(&a.created_at)); if !fetched.is_empty() {
ordered.extend(remaining); ordered.extend(fetched);
} }
ordered ordered
} else { } else {
docs_query docs_query.load(&mut conn)?
.order(documents::created_at.desc())
.load(&mut conn)?
}; };
let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?; let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?;
+26 -5
View File
@@ -16,7 +16,10 @@ use crate::{
}; };
use super::documents::{hydrate_documents, DocumentResponse}; use super::documents::{hydrate_documents, DocumentResponse};
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
use crate::utils::{json::deserialize_patch_field, time::to_iso}; use crate::utils::{json::deserialize_patch_field, time::to_iso};
use diesel::dsl::sql;
use diesel::sql_types::Text;
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest { pub struct CreateFolderRequest {
@@ -51,6 +54,12 @@ pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")] #[serde(default = "default_include_documents")]
#[schema(default = true)] #[schema(default = true)]
pub include_documents: bool, pub include_documents: bool,
#[serde(default)]
#[schema(default = "title")]
pub sort: DocumentSortField,
#[serde(default)]
#[schema(default = "asc")]
pub dir: SortDirection,
} }
const fn default_include_documents() -> bool { const fn default_include_documents() -> bool {
@@ -335,6 +344,12 @@ pub async fn list_folder_contents(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<Json<FolderContentsResponse>> {
let FolderContentsQuery {
include_documents,
sort,
dir,
} = query;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
} else { } else {
@@ -358,22 +373,28 @@ pub async fn list_folder_contents(
folders::table folders::table
.filter(folders::parent_id.eq(parent_id)) .filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id)) .filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(&mut conn)? .load(&mut conn)?
} else { } else {
folders::table folders::table
.filter(folders::parent_id.is_null()) .filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id)) .filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc()) .order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(&mut conn)? .load(&mut conn)?
}; };
let subfolders = child_folders.into_iter().map(folder_to_info).collect(); let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let documents = if query.include_documents { let documents = if include_documents {
let docs_query = documents::table let mut docs_query = documents::table
.filter(documents::deleted_at.is_null()) .filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id)) .filter(documents::tenant_id.eq(tenant_id))
.order(documents::created_at.desc()); .into_boxed();
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
docs_query = docs_query.order(sql::<Text>(primary_sql));
if let Some(second) = secondary_sql {
docs_query = docs_query.then_order_by(sql::<Text>(second));
}
let docs: Vec<Document> = if let Some(current_folder) = folder_id { let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query docs_query
+2 -1
View File
@@ -121,7 +121,8 @@ pub async fn create_api_token(
None => None, None => None,
}; };
let capability_set_id = validate_capability_set(&mut conn, tenant_id, payload.capability_set_id)?; let capability_set_id =
validate_capability_set(&mut conn, tenant_id, payload.capability_set_id)?;
let issued = issue_token( let issued = issue_token(
&mut conn, &mut conn,
+28 -39
View File
@@ -75,16 +75,11 @@ async fn api_token_crud_flow() -> Result<()> {
app.insert_user(username, password, "admin").await?; app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?; let access_token = app.login_token(username, password).await?;
let legacy_set_id = ensure_capability_set_slug( let legacy_set_id =
&app, ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
&access_token,
"legacy_webdav",
LEGACY_WEBDAV_CAPS,
)
.await?; .await?;
let created = let created = create_token(&app, &access_token, Some("dav"), legacy_set_id, None).await?;
create_token(&app, &access_token, Some("dav"), legacy_set_id, None).await?;
let token_id = created.info.id; let token_id = created.info.id;
assert_eq!(created.info.label.as_deref(), Some("dav")); assert_eq!(created.info.label.as_deref(), Some("dav"));
assert!(created.info.last_used_at.is_none()); assert!(created.info.last_used_at.is_none());
@@ -110,13 +105,8 @@ async fn api_token_crud_flow() -> Result<()> {
}) })
.await?; .await?;
let readonly_set_id = ensure_capability_set_slug( let readonly_set_id =
&app, ensure_capability_set_slug(&app, &access_token, "readonly", READ_ONLY_CAPS).await?;
&access_token,
"readonly",
READ_ONLY_CAPS,
)
.await?;
let readonly_token = let readonly_token =
create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?; create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?;
@@ -134,9 +124,11 @@ async fn api_token_crud_flow() -> Result<()> {
delete_token(&app, &access_token, token_id).await?; delete_token(&app, &access_token, token_id).await?;
let listed_after = list_tokens(&app, &access_token).await?; let listed_after = list_tokens(&app, &access_token).await?;
assert_eq!(listed_after.len(), 1); let revoked_entry = listed_after
assert_eq!(listed_after[0].id, token_id); .iter()
assert!(listed_after[0].revoked_at.is_some()); .find(|entry| entry.id == token_id)
.expect("revoked token still listed");
assert!(revoked_entry.revoked_at.is_some());
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
@@ -152,16 +144,11 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
app.insert_user(username, password, "admin").await?; app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?; let access_token = app.login_token(username, password).await?;
let legacy_set_id = ensure_capability_set_slug( let legacy_set_id =
&app, ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
&access_token,
"legacy_webdav",
LEGACY_WEBDAV_CAPS,
)
.await?; .await?;
let created = let created = create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?;
create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?;
let token_id = created.info.id; let token_id = created.info.id;
let router = webdav::create_router().with_state(app.state.clone()); let router = webdav::create_router().with_state(app.state.clone());
@@ -230,13 +217,8 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
assert_eq!(response.status(), StatusCode::MULTI_STATUS); assert_eq!(response.status(), StatusCode::MULTI_STATUS);
// Token without webdav_read cannot authenticate. // Token without webdav_read cannot authenticate.
let read_only_set_id = ensure_capability_set_slug( let read_only_set_id =
&app, ensure_capability_set_slug(&app, &access_token, "documents_read", READ_ONLY_CAPS).await?;
&access_token,
"documents_read",
READ_ONLY_CAPS,
)
.await?;
let limited_token = let limited_token =
create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?; create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?;
@@ -264,8 +246,14 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
) )
.await?; .await?;
let upgraded_token = let upgraded_token = create_token(
create_token(&app, &access_token, Some("limited-webdav"), limited_set_id, None).await?; &app,
&access_token,
Some("limited-webdav"),
limited_set_id,
None,
)
.await?;
assert_eq!(upgraded_token.info.capability_set_id, limited_set_id); assert_eq!(upgraded_token.info.capability_set_id, limited_set_id);
let upgraded_request = Request::builder() let upgraded_request = Request::builder()
@@ -394,16 +382,17 @@ async fn find_capability_set_slug(
slug: &str, slug: &str,
) -> Result<Option<Uuid>> { ) -> Result<Option<Uuid>> {
let sets = list_capability_sets(app, access_token).await?; let sets = list_capability_sets(app, access_token).await?;
Ok(sets.into_iter().find(|set| set.slug == slug).map(|set| set.id)) Ok(sets
.into_iter()
.find(|set| set.slug == slug)
.map(|set| set.id))
} }
async fn list_capability_sets( async fn list_capability_sets(
app: &TestApp, app: &TestApp,
access_token: &str, access_token: &str,
) -> Result<Vec<CapabilitySetSummary>> { ) -> Result<Vec<CapabilitySetSummary>> {
let response = app let response = app.get("/api/capability-sets", Some(access_token)).await?;
.get("/api/capability-sets", Some(access_token))
.await?;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?) Ok(serde_json::from_slice(&body)?)
+4 -1
View File
@@ -46,7 +46,10 @@ async fn capability_set_crud_flow() -> Result<()> {
let capabilities: Vec<String> = serde_json::from_slice(&capabilities_body)?; let capabilities: Vec<String> = serde_json::from_slice(&capabilities_body)?;
assert!(capabilities.contains(&"documents:read".to_string())); assert!(capabilities.contains(&"documents:read".to_string()));
assert!(capabilities.contains(&"capability_sets:write".to_string())); assert!(capabilities.contains(&"capability_sets:write".to_string()));
assert_eq!(capabilities.len(), 18); assert_eq!(
capabilities.len(),
papercrate::models::ApiCapability::variants().len()
);
// Create a new capability set. // Create a new capability set.
let create = app let create = app
+79
View File
@@ -277,6 +277,85 @@ async fn upload_document_with_custom_title_sets_filename() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test]
async fn document_list_sorting_controls() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "passw0rd";
app.insert_user("sorting", password, "admin").await?;
let token = app.login_token("sorting", password).await?;
let first = app
.upload_document_with_options(
"/api/documents",
"alpha.txt",
"text/plain",
b"alpha",
None,
Some("Alpha"),
None,
&token,
)
.await?;
assert!(first.status().is_success());
let first_body = body_to_vec(first.into_body()).await?;
let first_doc: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document_with_options(
"/api/documents",
"zulu.txt",
"text/plain",
b"zulu",
None,
Some("Zulu"),
None,
&token,
)
.await?;
assert!(second.status().is_success());
let second_body = body_to_vec(second.into_body()).await?;
let second_doc: DocumentDetail = serde_json::from_slice(&second_body)?;
// Default sort should be title ASC => Alpha first.
let default_resp = app.get("/api/documents", Some(&token)).await?;
assert_eq!(default_resp.status(), StatusCode::OK);
let default_body = body_to_vec(default_resp.into_body()).await?;
let default_list: Vec<DocumentListItem> = serde_json::from_slice(&default_body)?;
assert_eq!(default_list.len(), 2);
assert_eq!(default_list[0].id, first_doc.document.id);
assert_eq!(default_list[1].id, second_doc.document.id);
// Sort by created_at DESC, expecting most recent (second) first.
let created_desc = app
.get("/api/documents?sort=created_at&dir=desc", Some(&token))
.await?;
assert_eq!(created_desc.status(), StatusCode::OK);
let created_body = body_to_vec(created_desc.into_body()).await?;
let created_list: Vec<DocumentListItem> = serde_json::from_slice(&created_body)?;
assert_eq!(created_list.len(), 2);
assert_eq!(created_list[0].id, second_doc.document.id);
assert_eq!(created_list[1].id, first_doc.document.id);
// Folder contents respects the same parameters.
let folder_resp = app
.get(
"/api/folders/root/contents?sort=created_at&dir=desc",
Some(&token),
)
.await?;
assert_eq!(folder_resp.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder_contents: FolderContents = serde_json::from_slice(&folder_body)?;
assert_eq!(folder_contents.documents.len(), 2);
assert_eq!(folder_contents.documents[0].id, second_doc.document.id);
assert_eq!(folder_contents.documents[1].id, first_doc.document.id);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn duplicate_and_restore_document() -> Result<()> { async fn duplicate_and_restore_document() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;