Files
coder/coderd/database/queries/organizationmembers.sql
T
Andrew Aquino 09a69e624a feat: search users by display name (#27398)
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._
2026-07-28 12:13:58 -07:00

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);