Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1591,8 +1591,9 @@ impl Db {
community_id: CommunityId,
query: &str,
limit: u32,
offset: u32,
) -> Result<Vec<user::UserSearchProfile>> {
user::search_users(&self.pool, community_id, query, limit).await
user::search_users(&self.pool, community_id, query, limit, offset).await
}

/// Atomically set agent owner — only if no owner is currently assigned.
Expand Down
123 changes: 103 additions & 20 deletions crates/buzz-db/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct UserSearchProfile {
pub avatar_url: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05_handle: Option<String>,
/// Whether this user has ever published a kind:0 metadata event, including
/// one that has since been soft-deleted.
pub has_metadata_history: bool,
}

/// Ensure a user record exists for the given pubkey (upsert).
Expand Down Expand Up @@ -221,11 +224,16 @@ fn escape_like(input: &str) -> String {
/// Search users by display name, NIP-05 handle, or pubkey prefix.
///
/// Empty queries return an empty vec and do not hit the database.
///
/// `offset` is 0-based and used for bridge people-directory paging (the desktop
/// member picker pages with `page`/`limit`). Clamped so a huge offset cannot
/// force an unbounded scan beyond the page window.
pub async fn search_users(
pool: &PgPool,
community_id: CommunityId,
query: &str,
limit: u32,
offset: u32,
) -> Result<Vec<UserSearchProfile>> {
let normalized = query.trim().to_lowercase();
if normalized.is_empty() {
Expand All @@ -236,45 +244,75 @@ pub async fn search_users(
let contains_pattern = format!("%{escaped}%");
let prefix_pattern = format!("{escaped}%");
let limit = limit.clamp(1, 500) as i64;
let offset = offset.min(100_000) as i64;

let rows = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
let rows = sqlx::query_as::<
_,
(
Vec<u8>,
Option<String>,
Option<String>,
Option<String>,
bool,
),
>(
r#"
SELECT pubkey, display_name, avatar_url, nip05_handle
FROM users
WHERE community_id = $1
AND (LOWER(COALESCE(display_name, '')) LIKE $2 ESCAPE '\'
OR LOWER(COALESCE(nip05_handle, '')) LIKE $2 ESCAPE '\'
OR LOWER(encode(pubkey, 'hex')) LIKE $2 ESCAPE '\')
SELECT
u.pubkey,
u.display_name,
u.avatar_url,
u.nip05_handle,
EXISTS (
SELECT 1
FROM events e
WHERE e.community_id = u.community_id
AND e.pubkey = u.pubkey
AND e.kind = 0
) AS has_metadata_history
FROM users u
WHERE u.community_id = $1
AND (LOWER(COALESCE(u.display_name, '')) LIKE $2 ESCAPE '\'
OR LOWER(COALESCE(u.nip05_handle, '')) LIKE $2 ESCAPE '\'
OR LOWER(encode(u.pubkey, 'hex')) LIKE $2 ESCAPE '\')
ORDER BY
CASE
WHEN LOWER(COALESCE(display_name, '')) = $3 THEN 0
WHEN LOWER(COALESCE(nip05_handle, '')) = $3 THEN 1
WHEN LOWER(encode(pubkey, 'hex')) = $3 THEN 2
WHEN LOWER(COALESCE(display_name, '')) LIKE $4 ESCAPE '\' THEN 3
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4
WHEN LOWER(encode(pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5
WHEN LOWER(COALESCE(u.display_name, '')) = $3 THEN 0
WHEN LOWER(COALESCE(u.nip05_handle, '')) = $3 THEN 1
WHEN LOWER(encode(u.pubkey, 'hex')) = $3 THEN 2
WHEN LOWER(COALESCE(u.display_name, '')) LIKE $4 ESCAPE '\' THEN 3
WHEN LOWER(COALESCE(u.nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4
WHEN LOWER(encode(u.pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5
ELSE 6
END,
COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex')))
LIMIT $5
COALESCE(
NULLIF(u.display_name, ''),
NULLIF(u.nip05_handle, ''),
LOWER(encode(u.pubkey, 'hex'))
),
LOWER(encode(u.pubkey, 'hex'))
LIMIT $5 OFFSET $6
"#,
)
.bind(community_id.as_uuid())
.bind(&contains_pattern)
.bind(&normalized)
.bind(&prefix_pattern)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;

Ok(rows
.into_iter()
.map(
|(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile {
pubkey,
display_name,
avatar_url,
nip05_handle,
|(pubkey, display_name, avatar_url, nip05_handle, has_metadata_history)| {
UserSearchProfile {
pubkey,
display_name,
avatar_url,
nip05_handle,
has_metadata_history,
}
},
)
.collect())
Expand Down Expand Up @@ -646,6 +684,51 @@ mod tests {
assert_eq!(escape_like(""), "");
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn search_users_pages_tied_rows_by_pubkey() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let pubkeys = vec![
vec![0x30; 32],
vec![0x10; 32],
vec![0x40; 32],
vec![0x20; 32],
];

for pubkey in &pubkeys {
ensure_user(&db.pool, community, pubkey)
.await
.expect("ensure tied user");
update_user_profile(&db.pool, community, pubkey, Some("Alex"), None, None, None)
.await
.expect("set tied display name");
}

let first_page = search_users(&db.pool, community, "alex", 2, 0)
.await
.expect("query first page");
let second_page = search_users(&db.pool, community, "alex", 2, 2)
.await
.expect("query second page");
let actual: Vec<Vec<u8>> = first_page
.into_iter()
.chain(second_page)
.map(|user| user.pubkey)
.collect();

assert_eq!(
actual,
vec![
vec![0x10; 32],
vec![0x20; 32],
vec![0x30; 32],
vec![0x40; 32],
],
"tied users must cross page boundaries in deterministic pubkey order"
);
}

/// A user with "owner_only" policy but no agent_owner_pubkey set should
/// return Some(("owner_only", None)).
#[tokio::test]
Expand Down
Loading