mirror of
https://github.com/coder/coder.git
synced 2026-09-23 05:43:53 +08:00
Free-text member search previously matched only username and email, so typing a person's display name returned no results even though the UI shows the display name as the primary label. This broadens the free-text `@search` filter to also match `users.name`. The change is in three queries: `GetUsers`, `PaginatedOrganizationMembers`, and `GetGroupMembersByGroupIDPaginated`. This covers every server-filtered surface: the Users page, the Organization Members page, the Group Members page, and the `UserAutocomplete` / `WorkspaceUserAutocomplete` pickers (which query `GetUsers` with `q`). The org member picker (`MemberAutocomplete`) filters client-side via cmdk, so display name is added to its `keywords`. Explicit filters (`name:`, `username`/`email`) and pagination counts are unchanged; the group members count still comes from the filtered `COUNT(*) OVER()` in the same query. Refs DEVEX-484 Refs DEVEX-565 <details> <summary>Implementation plan</summary> ## Problem Member search (both the global Users page and the Organization Members page) matches only on `username` and `email`. It does not match on the user's display name (`users.name`), even though the Organization Members table shows `name` as the primary title. So typing a person's full name in the search box returns nothing. Today a bare search term (`alice`) is routed to the SQL `@search` filter, which only checks `email`/`username`. Display name is only matched if the user explicitly types `name:alice`, which is undiscoverable. ## Design decision Include `name` in the free-text `@search` condition in the affected SQL queries. A bare term then matches `email OR username OR name`, using the same case-insensitive substring `ILIKE` already in place. This keeps the existing explicit `name:` filter working. Tradeoff: this broadens the meaning of free-text `search` globally (anything using these queries now also matches display name). This is the intended behavior, confirmed against DEVEX-565 (display name search in the user picker). ## Affected files Backend: - `coderd/database/queries/users.sql` (`GetUsers`) - `coderd/database/queries/organizationmembers.sql` (`PaginatedOrganizationMembers`) - `coderd/database/queries/groupmembers.sql` (`GetGroupMembersByGroupIDPaginated`) - `coderd/database/queries.sql.go` regenerated via `make gen` Frontend: - `site/src/components/UserAutocomplete/UserAutocomplete.tsx` (add `name` to client-side cmdk keywords) Tests: - `coderd/coderdtest/users.go` (shared `UsersFilter` helper): added a `DisplayNameSearch` case and extended search-based expectations to include `name`. Exercised by `TestGetUsersFilter`, `TestGetOrgMembersFilter`, and `TestGetGroupMembersFilter`. Docs: - `docs/admin/users/index.md`: documented that free-text search matches username, email, and display name. ## Frontend surface coverage | Surface | Sends | Backend | Query | |---|---|---|---| | Users page | `q` | `GET /users` | `GetUsers` | | Organization Members page | `q` | paginated members | `PaginatedOrganizationMembers` | | Group Members page | `q` | `groupMembers` | `GetGroupMembersByGroupIDPaginated` | | User pickers (server-filtered) | `q` | `GET /users` | `GetUsers` | | Org member picker (client-filtered) | local cmdk | n/a | keyword change | ## Out of scope - Trigram/similarity (fuzzy) matching; keeps `ILIKE` substring semantics. - Sort/pagination ordering (still `LOWER(username)`). </details> --- _Created by Coder Agents on behalf of @aqandrew._
207 lines
5.9 KiB
SQL
207 lines
5.9 KiB
SQL
-- name: OrganizationMembers :many
|
|
-- Arguments are optional with uuid.Nil to ignore.
|
|
-- - Use just 'organization_id' to get all members of an org
|
|
-- - Use just 'user_id' to get all orgs a user is a member of
|
|
-- - Use both to get a specific org member row
|
|
SELECT
|
|
sqlc.embed(organization_members),
|
|
users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles",
|
|
users.last_seen_at, users.status, users.login_type, users.is_service_account,
|
|
users.created_at as user_created_at, users.updated_at as user_updated_at
|
|
FROM
|
|
organization_members
|
|
INNER JOIN
|
|
users ON organization_members.user_id = users.id AND users.deleted = false
|
|
WHERE
|
|
-- Filter by organization id
|
|
CASE
|
|
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
|
organization_id = @organization_id
|
|
ELSE true
|
|
END
|
|
-- Filter by user id
|
|
AND CASE
|
|
WHEN @user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
|
user_id = @user_id
|
|
ELSE true
|
|
END
|
|
-- Filter by system type
|
|
AND CASE
|
|
WHEN @include_system::bool THEN TRUE
|
|
ELSE
|
|
is_system = false
|
|
END
|
|
-- Filter by github user ID. Note that this requires a join on the users table.
|
|
AND CASE
|
|
WHEN @github_user_id :: bigint != 0 THEN
|
|
users.github_com_user_id = @github_user_id
|
|
ELSE true
|
|
END;
|
|
|
|
-- name: InsertOrganizationMember :one
|
|
INSERT INTO
|
|
organization_members (
|
|
organization_id,
|
|
user_id,
|
|
created_at,
|
|
updated_at,
|
|
roles
|
|
)
|
|
VALUES
|
|
($1, $2, $3, $4, $5) RETURNING *;
|
|
|
|
-- name: DeleteOrganizationMember :exec
|
|
DELETE
|
|
FROM
|
|
organization_members
|
|
WHERE
|
|
organization_id = @organization_id AND
|
|
user_id = @user_id
|
|
;
|
|
|
|
|
|
-- name: GetOrganizationIDsByMemberIDs :many
|
|
SELECT
|
|
user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs"
|
|
FROM
|
|
organization_members
|
|
WHERE
|
|
user_id = ANY(@ids :: uuid [ ])
|
|
GROUP BY
|
|
user_id;
|
|
|
|
-- name: UpdateMemberRoles :one
|
|
UPDATE
|
|
organization_members
|
|
SET
|
|
-- Remove all duplicates from the roles.
|
|
roles = ARRAY(SELECT DISTINCT UNNEST(@granted_roles :: text[]))
|
|
WHERE
|
|
user_id = @user_id
|
|
AND organization_id = @org_id
|
|
RETURNING *;
|
|
|
|
-- name: PaginatedOrganizationMembers :many
|
|
SELECT
|
|
sqlc.embed(organization_members),
|
|
users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles",
|
|
users.last_seen_at, users.status, users.login_type, users.is_service_account,
|
|
users.created_at as user_created_at, users.updated_at as user_updated_at,
|
|
COUNT(*) OVER() AS count
|
|
FROM
|
|
organization_members
|
|
INNER JOIN
|
|
users ON organization_members.user_id = users.id AND users.deleted = false
|
|
WHERE
|
|
CASE
|
|
-- This allows using the last element on a page as effectively a cursor.
|
|
-- This is an important option for scripts that need to paginate without
|
|
-- duplicating or missing data.
|
|
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
|
-- The pagination cursor is the last ID of the previous page.
|
|
-- The query is ordered by the username field, so select all
|
|
-- rows after the cursor.
|
|
(LOWER(users.username)) > (
|
|
SELECT
|
|
LOWER(users.username)
|
|
FROM
|
|
organization_members
|
|
INNER JOIN
|
|
users ON organization_members.user_id = users.id
|
|
WHERE
|
|
organization_members.user_id = @after_id
|
|
)
|
|
)
|
|
ELSE true
|
|
END
|
|
-- Start filters
|
|
-- Filter by organization id
|
|
AND CASE
|
|
WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN
|
|
organization_id = @organization_id
|
|
ELSE true
|
|
END
|
|
-- Filter by email, username, or name (display name)
|
|
AND CASE
|
|
WHEN @search :: text != '' THEN (
|
|
users.email ILIKE concat('%', @search, '%')
|
|
OR users.username ILIKE concat('%', @search, '%')
|
|
OR users.name ILIKE concat('%', @search, '%')
|
|
)
|
|
ELSE true
|
|
END
|
|
-- Filter by name (display name)
|
|
AND CASE
|
|
WHEN @name :: text != '' THEN
|
|
users.name ILIKE concat('%', @name, '%')
|
|
ELSE true
|
|
END
|
|
-- Filter by status
|
|
AND CASE
|
|
-- @status needs to be a text because it can be empty, If it was
|
|
-- user_status enum, it would not.
|
|
WHEN cardinality(@status :: user_status[]) > 0 THEN
|
|
users.status = ANY(@status :: user_status[])
|
|
ELSE true
|
|
END
|
|
-- Filter by global rbac_roles
|
|
AND CASE
|
|
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
|
|
-- everyone is a member.
|
|
WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN
|
|
users.rbac_roles && @rbac_role :: text[]
|
|
ELSE true
|
|
END
|
|
-- Filter by last_seen
|
|
AND CASE
|
|
WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
users.last_seen_at <= @last_seen_before
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
users.last_seen_at >= @last_seen_after
|
|
ELSE true
|
|
END
|
|
-- Filter by created_at (user creation date, not date added to org)
|
|
AND CASE
|
|
WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
users.created_at <= @created_before
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
|
users.created_at >= @created_after
|
|
ELSE true
|
|
END
|
|
-- Filter by system type
|
|
AND CASE
|
|
WHEN @include_system::bool THEN TRUE
|
|
ELSE users.is_system = false
|
|
END
|
|
-- Filter by github.com user ID
|
|
AND CASE
|
|
WHEN @github_com_user_id :: bigint != 0 THEN
|
|
users.github_com_user_id = @github_com_user_id
|
|
ELSE true
|
|
END
|
|
-- Filter by login_type
|
|
AND CASE
|
|
WHEN cardinality(@login_type :: login_type[]) > 0 THEN
|
|
users.login_type = ANY(@login_type :: login_type[])
|
|
ELSE true
|
|
END
|
|
-- Filter by service account.
|
|
AND CASE
|
|
WHEN sqlc.narg('is_service_account') :: boolean IS NOT NULL THEN
|
|
users.is_service_account = sqlc.narg('is_service_account') :: boolean
|
|
ELSE true
|
|
END
|
|
-- End of filters
|
|
ORDER BY
|
|
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
|
|
LOWER(users.username) ASC OFFSET @offset_opt
|
|
LIMIT
|
|
-- A null limit means "no limit", so 0 means return all
|
|
NULLIF(@limit_opt :: int, 0);
|