diff --git a/crates/handlers/src/admin/v1/users/list.rs b/crates/handlers/src/admin/v1/users/list.rs index 17b652c82..da70e5807 100644 --- a/crates/handlers/src/admin/v1/users/list.rs +++ b/crates/handlers/src/admin/v1/users/list.rs @@ -58,6 +58,13 @@ pub struct FilterParams { #[serde(rename = "filter[legacy-guest]")] legacy_guest: Option, + /// Retrieve users where the username matches contains the given string + /// + /// Note that this doesn't change the ordering of the result, which are + /// still ordered by ID. + #[serde(rename = "filter[search]")] + search: Option, + /// Retrieve the items with the given status /// /// Defaults to retrieve all users, including locked ones. @@ -83,6 +90,10 @@ impl std::fmt::Display for FilterParams { write!(f, "{sep}filter[legacy-guest]={legacy_guest}")?; sep = '&'; } + if let Some(search) = &self.search { + write!(f, "{sep}filter[search]={search}")?; + sep = '&'; + } if let Some(status) = self.status { write!(f, "{sep}filter[status]={status}")?; sep = '&'; @@ -157,6 +168,11 @@ pub async fn handler( None => filter, }; + let filter = match params.search.as_deref() { + Some(search) => filter.matching_search(search), + None => filter, + }; + let filter = match params.status { Some(UserStatus::Active) => filter.active_only(), Some(UserStatus::Locked) => filter.locked_only(), diff --git a/crates/storage-pg/migrations/20250915092000_pgtrgm_extension.sql b/crates/storage-pg/migrations/20250915092000_pgtrgm_extension.sql new file mode 100644 index 000000000..2ebc26d25 --- /dev/null +++ b/crates/storage-pg/migrations/20250915092000_pgtrgm_extension.sql @@ -0,0 +1,10 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This enables the pg_trgm extension, which is used for search filters +-- Starting Posgres 16, this extension is marked as "trusted", meaning it can be +-- installed by non-superusers +CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/crates/storage-pg/migrations/20250915092635_users_username_trgm_idx.sql b/crates/storage-pg/migrations/20250915092635_users_username_trgm_idx.sql new file mode 100644 index 000000000..5f007d750 --- /dev/null +++ b/crates/storage-pg/migrations/20250915092635_users_username_trgm_idx.sql @@ -0,0 +1,10 @@ +-- no-transaction +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE in the repository root for full details. + +-- This adds an index on the username field for ILIKE '%search%' operations, +-- enabling fuzzy searches of usernames +CREATE INDEX CONCURRENTLY users_username_trgm_idx + ON users USING gin(username gin_trgm_ops); diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 1af09a455..0be594556 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use mas_data_model::{Clock, User}; use mas_storage::user::{UserFilter, UserRepository}; use rand::RngCore; -use sea_query::{Expr, PostgresQueryBuilder, Query}; +use sea_query::{Expr, PostgresQueryBuilder, Query, extension::postgres::PgExpr as _}; use sea_query_binder::SqlxBinder; use sqlx::PgConnection; use ulid::Ulid; @@ -120,6 +120,9 @@ impl Filter for UserFilter<'_> { self.is_guest() .map(|is_guest| Expr::col((Users::Table, Users::IsGuest)).eq(is_guest)), ) + .add_option(self.search().map(|search| { + Expr::col((Users::Table, Users::Username)).ilike(format!("%{search}%")) + })) } } diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 0880c1032..0ee978914 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -174,6 +174,19 @@ async fn test_user_repo(pool: PgPool) { assert_eq!(repo.user().count(locked).await.unwrap(), 0); assert_eq!(repo.user().count(deactivated).await.unwrap(), 1); + // Test the search filter + assert_eq!( + repo.user() + .count(all.matching_search("alice")) + .await + .unwrap(), + 0 + ); + assert_eq!( + repo.user().count(all.matching_search("JO")).await.unwrap(), + 1 + ); + // Check the list method let list = repo.user().list(all, Pagination::first(10)).await.unwrap(); assert_eq!(list.edges.len(), 1); diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 8a5949e81..657909ef4 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -76,10 +76,10 @@ pub struct UserFilter<'a> { state: Option, can_request_admin: Option, is_guest: Option, - _phantom: std::marker::PhantomData<&'a ()>, + search: Option<&'a str>, } -impl UserFilter<'_> { +impl<'a> UserFilter<'a> { /// Create a new [`UserFilter`] with default values #[must_use] pub fn new() -> Self { @@ -135,6 +135,13 @@ impl UserFilter<'_> { self } + /// Filter for users that match the given search string + #[must_use] + pub fn matching_search(mut self, search: &'a str) -> Self { + self.search = Some(search); + self + } + /// Get the state filter /// /// Returns [`None`] if no state filter was set @@ -158,6 +165,14 @@ impl UserFilter<'_> { pub fn is_guest(&self) -> Option { self.is_guest } + + /// Get the search filter + /// + /// Returns [`None`] if no search filter was set + #[must_use] + pub fn search(&self) -> Option<&'a str> { + self.search + } } /// A [`UserRepository`] helps interacting with [`User`] saved in the storage diff --git a/docs/api/spec.json b/docs/api/spec.json index 0e8acdded..3348cf722 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -915,6 +915,17 @@ }, "style": "form" }, + { + "in": "query", + "name": "filter[search]", + "description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.", + "schema": { + "description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.", + "type": "string", + "nullable": true + }, + "style": "form" + }, { "in": "query", "name": "filter[status]", @@ -3893,6 +3904,11 @@ "type": "boolean", "nullable": true }, + "filter[search]": { + "description": "Retrieve users where the username matches contains the given string\n\nNote that this doesn't change the ordering of the result, which are still ordered by ID.", + "type": "string", + "nullable": true + }, "filter[status]": { "description": "Retrieve the items with the given status\n\nDefaults to retrieve all users, including locked ones.\n\n* `active`: Only retrieve active users\n\n* `locked`: Only retrieve locked users (includes deactivated users)\n\n* `deactivated`: Only retrieve deactivated users", "$ref": "#/components/schemas/UserStatus",