Ctrl K
ComunidadGuías…

coderd/searchquery/search.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package searchquery

import (
	"context"
	"database/sql"
	"fmt"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/coderd/tracing"
	"github.com/coder/coder/v2/codersdk"
)

// AuditLogs requires the database to fetch an organization by name
// to convert to organization uuid.
//
// Supported query parameters:
//
//   - request_id: UUID (can be used to search for associated audits e.g. connect/disconnect or open/close)
//   - resource_id: UUID
//   - resource_target: string
//   - username: string
//   - email: string
//   - date_from: string (date in format "2006-01-02")
//   - date_to: string (date in format "2006-01-02")
//   - organization: string (organization UUID or name)
//   - resource_type: string (enum)
//   - action: string (enum)
//   - build_reason: string (enum)
func AuditLogs(ctx context.Context, db database.Store, query string) (database.GetAuditLogsOffsetParams,
	database.CountAuditLogsParams, []codersdk.ValidationError,
) {
	// Always lowercase for all searches.
	query = strings.ToLower(query)
	values, errors := searchTerms(query, func(term string, values url.Values) error {
		values.Add("resource_type", term)
		return nil
	})
	if len(errors) > 0 {
		// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
		return database.GetAuditLogsOffsetParams{}, database.CountAuditLogsParams{}, errors
	}

	const dateLayout = "2006-01-02"
	parser := httpapi.NewQueryParamParser()
	filter := database.GetAuditLogsOffsetParams{
		RequestID:      parser.UUID(values, uuid.Nil, "request_id"),
		ResourceID:     parser.UUID(values, uuid.Nil, "resource_id"),
		ResourceTarget: parser.String(values, "", "resource_target"),
		Username:       parser.String(values, "", "username"),
		Email:          parser.String(values, "", "email"),
		DateFrom:       parser.Time(values, time.Time{}, "date_from", dateLayout),
		DateTo:         parser.Time(values, time.Time{}, "date_to", dateLayout),
		OrganizationID: parseOrganization(ctx, db, parser, values, "organization"),
		ResourceType:   string(httpapi.ParseCustom(parser, values, "", "resource_type", httpapi.ParseEnum[database.ResourceType])),
		Action:         string(httpapi.ParseCustom(parser, values, "", "action", httpapi.ParseEnum[database.AuditAction])),
		BuildReason:    string(httpapi.ParseCustom(parser, values, "", "build_reason", httpapi.ParseEnum[database.BuildReason])),
	}
	if !filter.DateTo.IsZero() {
		filter.DateTo = filter.DateTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
	}

	// Prepare the count filter, which uses the same parameters as the GetAuditLogsOffsetParams.
	// nolint:exhaustruct // UserID and CountCap are not obtained from the query parameters.
	countFilter := database.CountAuditLogsParams{
		RequestID:      filter.RequestID,
		ResourceID:     filter.ResourceID,
		ResourceTarget: filter.ResourceTarget,
		Username:       filter.Username,
		Email:          filter.Email,
		DateFrom:       filter.DateFrom,
		DateTo:         filter.DateTo,
		OrganizationID: filter.OrganizationID,
		ResourceType:   filter.ResourceType,
		Action:         filter.Action,
		BuildReason:    filter.BuildReason,
	}

	parser.ErrorExcessParams(values)
	return filter, countFilter, parser.Errors
}

func ConnectionLogs(ctx context.Context, db database.Store, query string, apiKey database.APIKey) (database.GetConnectionLogsOffsetParams, database.CountConnectionLogsParams, []codersdk.ValidationError) {
	// Always lowercase for all searches.
	query = strings.ToLower(query)
	values, errors := searchTerms(query, func(term string, values url.Values) error {
		values.Add("search", term)
		return nil
	})
	if len(errors) > 0 {
		// nolint:exhaustruct // We don't need to initialize these structs because we return an error.
		return database.GetConnectionLogsOffsetParams{}, database.CountConnectionLogsParams{}, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter := database.GetConnectionLogsOffsetParams{
		OrganizationID:      parseOrganization(ctx, db, parser, values, "organization"),
		WorkspaceOwner:      parser.String(values, "", "workspace_owner"),
		WorkspaceOwnerEmail: parser.String(values, "", "workspace_owner_email"),
		Username:            parser.String(values, "", "username"),
		UserEmail:           parser.String(values, "", "user_email"),
		ConnectedAfter:      parser.Time3339Nano(values, time.Time{}, "connected_after"),
		ConnectedBefore:     parser.Time3339Nano(values, time.Time{}, "connected_before"),
		WorkspaceID:         parser.UUID(values, uuid.Nil, "workspace_id"),
		ConnectionID:        parser.UUID(values, uuid.Nil, "connection_id"),
		ClientSessionID:     parser.String(values, "", "client_session_id"),
		Status:              string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[codersdk.ConnectionLogStatus])),
		ConnectionMethod:    string(httpapi.ParseCustom(parser, values, "", "method", httpapi.ParseEnum[codersdk.ConnectionLogMethod])),
	}
	if values.Has("client_session_id") && !tracing.ValidSessionID(filter.ClientSessionID) {
		parser.Errors = append(parser.Errors, codersdk.ValidationError{
			Field:  "client_session_id",
			Detail: "client_session_id must be 32 hexadecimal characters",
		})
	}
	apps := parser.Strings(values, nil, "app")
	typ := httpapi.ParseCustom(parser, values, "", "type", httpapi.ParseEnum[codersdk.ConnectionType])
	switch {
	case typ != "" && (filter.ConnectionMethod != "" || len(apps) > 0):
		parser.Errors = append(parser.Errors, codersdk.ValidationError{
			Field:  "type",
			Detail: "type cannot be combined with method or app",
		})
	case typ != "":
		applyConnectionTypeFilter(&filter, typ)
	case len(apps) > 0:
		// Repeated app terms match any of the apps.
		for i, app := range apps {
			apps[i] = codersdk.NormalizeAppName(app)
		}
		slices.Sort(apps)
		filter.AppNames = slices.Compact(apps)
	}

	if filter.Username == "me" {
		filter.UserID = apiKey.UserID
		filter.Username = ""
	}

	if filter.WorkspaceOwner == "me" {
		filter.WorkspaceOwnerID = apiKey.UserID
		filter.WorkspaceOwner = ""
	}

	// This MUST be kept in sync with the above
	// nolint:exhaustruct // CountCap is not obtained from the query parameters.
	countFilter := database.CountConnectionLogsParams{
		OrganizationID:      filter.OrganizationID,
		WorkspaceOwner:      filter.WorkspaceOwner,
		WorkspaceOwnerID:    filter.WorkspaceOwnerID,
		WorkspaceOwnerEmail: filter.WorkspaceOwnerEmail,
		ConnectionMethod:    filter.ConnectionMethod,
		AppNames:            filter.AppNames,
		ExcludedAppNames:    filter.ExcludedAppNames,

		UserID:          filter.UserID,
		Username:        filter.Username,
		UserEmail:       filter.UserEmail,
		ConnectedAfter:  filter.ConnectedAfter,
		ConnectedBefore: filter.ConnectedBefore,
		WorkspaceID:     filter.WorkspaceID,
		ConnectionID:    filter.ConnectionID,
		ClientSessionID: filter.ClientSessionID,
		Status:          filter.Status,
	}
	parser.ErrorExcessParams(values)
	return filter, countFilter, parser.Errors
}

// applyConnectionTypeFilter translates the legacy type filter. It mirrors
// db2sdk.ConnectionLogType so a row matches the type it is returned with.
// Remove it, ExcludedAppNames, and ConnectionLog.Type together.
func applyConnectionTypeFilter(filter *database.GetConnectionLogsOffsetParams, typ codersdk.ConnectionType) {
	switch typ {
	case codersdk.ConnectionTypeVSCode, codersdk.ConnectionTypeJetBrains:
		filter.ConnectionMethod = string(database.ConnectionLogMethodSSH)
		filter.AppNames = appsInFamily(codersdk.AppFamilyName(typ))
	case codersdk.ConnectionTypeSSH:
		// Plain SSH also matches absent and unregistered apps.
		filter.ConnectionMethod = string(database.ConnectionLogMethodSSH)
		filter.ExcludedAppNames = slices.Concat(appsInFamily(codersdk.AppFamilyVSCode), appsInFamily(codersdk.AppFamilyJetBrains))
	default:
		filter.ConnectionMethod = string(typ)
	}
}

func appsInFamily(family codersdk.AppFamilyName) []string {
	var apps []string
	for appName, appFamily := range codersdk.SessionCountAppFamilies() {
		if appFamily == family {
			apps = append(apps, appName)
		}
	}
	slices.Sort(apps)
	return apps
}

func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
	// Always lowercase for all searches.
	query = strings.ToLower(query)
	values, errors := searchTerms(query, func(term string, values url.Values) error {
		values.Add("search", term)
		return nil
	})
	if len(errors) > 0 {
		return database.GetUsersParams{}, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter := database.GetUsersParams{
		Search:           parser.String(values, "", "search"),
		Name:             parser.String(values, "", "name"),
		ExactUsername:    parser.String(values, "", "username"),
		ExactEmail:       parser.String(values, "", "email"),
		Status:           httpapi.ParseCustomList(parser, values, []database.UserStatus{}, "status", httpapi.ParseEnum[database.UserStatus]),
		IsServiceAccount: parser.NullableBoolean(values, sql.NullBool{}, "service_account"),
		RbacRole:         parser.Strings(values, []string{}, "role"),
		LastSeenAfter:    parser.Time3339Nano(values, time.Time{}, "last_seen_after"),
		LastSeenBefore:   parser.Time3339Nano(values, time.Time{}, "last_seen_before"),
		CreatedAfter:     parser.Time3339Nano(values, time.Time{}, "created_after"),
		CreatedBefore:    parser.Time3339Nano(values, time.Time{}, "created_before"),
		GithubComUserID:  parser.Int64(values, 0, "github_com_user_id"),
		LoginType:        httpapi.ParseCustomList(parser, values, []database.LoginType{}, "login_type", httpapi.ParseEnum[database.LoginType]),
	}
	parser.ErrorExcessParams(values)
	return filter, parser.Errors
}

// Groups parses a group search query using the standard filter syntax shared
// with the rest of the dashboard. Bare terms (including multi-word terms)
// become a free-text search over group name and display name. A value that
// contains a colon must be quoted or supplied via the explicit search key,
// e.g. search:"team: frontend", because an unquoted colon is otherwise treated
// as a key:value delimiter. Unknown keys are rejected, which keeps room for
// real key:value filters in the future.
func Groups(query string) (string, []codersdk.ValidationError) {
	// Always lowercase for all searches.
	query = strings.ToLower(query)
	values, errors := searchTerms(query, func(term string, values url.Values) error {
		values.Add("search", term)
		return nil
	})
	if len(errors) > 0 {
		return "", errors
	}

	parser := httpapi.NewQueryParamParser()
	search := parser.String(values, "", "search")
	parser.ErrorExcessParams(values)
	return search, parser.Errors
}

func Members(query string, organizationID uuid.UUID) (database.OrganizationMembersParams, []codersdk.ValidationError) {
	query = strings.TrimSpace(query)
	if query == "" {
		return database.OrganizationMembersParams{
			OrganizationID: organizationID,
			UserID:         uuid.Nil,
			IncludeSystem:  false,
			GithubUserID:   0,
		}, nil
	}
	values, errors := searchTerms(query, func(term string, values url.Values) error {
		switch term {
		case "user_id":
			values.Set("user_id", "")
		case "github_user_id":
			values.Set("github_user_id", "")
		case "include_system":
			values.Set("include_system", "")
		default:
			return xerrors.Errorf("invalid search term: %s", term)
		}
		return nil
	})
	if len(errors) > 0 {
		return database.OrganizationMembersParams{
			OrganizationID: organizationID,
			UserID:         uuid.Nil,
			IncludeSystem:  false,
			GithubUserID:   0,
		}, errors
	}

	parser := httpapi.NewQueryParamParser()
	params := database.OrganizationMembersParams{
		OrganizationID: organizationID,
		UserID:         parser.UUID(values, uuid.Nil, "user_id"),
		IncludeSystem:  parser.Boolean(values, false, "include_system"),
		GithubUserID:   parser.Int64(values, 0, "github_user_id"),
	}
	parser.ErrorExcessParams(values)

	return params, parser.Errors
}

func Workspaces(ctx context.Context, db database.Store, query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration, actorID uuid.UUID) (database.GetWorkspacesParams, []codersdk.ValidationError) {
	filter := database.GetWorkspacesParams{
		AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),

		// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
		Offset: int32(page.Offset),
		// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
		Limit: int32(page.Limit),
	}

	if query == "" {
		return filter, nil
	}

	// Always lowercase for all searches.
	query = strings.ToLower(query)
	values, errors := searchTerms(query, func(term string, values url.Values) error {
		// It is a workspace name, and maybe includes an owner
		parts := splitQueryParameterByDelimiter(term, '/', false)
		switch len(parts) {
		case 1:
			values.Add("name", parts[0])
		case 2:
			values.Add("owner", parts[0])
			values.Add("name", parts[1])
		default:
			return xerrors.Errorf("Query element %q can only contain 1 '/'", term)
		}
		return nil
	})
	if len(errors) > 0 {
		return filter, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter.WorkspaceIds = parser.UUIDs(values, []uuid.UUID{}, "id")
	filter.OwnerUsername = parser.String(values, "", "owner")
	filter.TemplateName = parser.String(values, "", "template")
	filter.Name = parser.String(values, "", "name")
	filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
	filter.HasAgentStatuses = parser.Strings(values, []string{}, "has-agent")
	filter.Dormant = parser.Boolean(values, false, "dormant")
	filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after")
	filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before")
	filter.UsingActive = sql.NullBool{
		// Invert the value of the query parameter to get the correct value.
		// UsingActive returns if the workspace is on the latest template active version.
		Bool: !parser.Boolean(values, true, "outdated"),
		// Only include this search term if it was provided. Otherwise default to omitting it
		// which will return all workspaces.
		Valid: values.Has("outdated"),
	}
	filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent")
	// include_agent_metadata expands the response with the named agent
	// metadata keys; it does not filter the returned workspaces.
	filter.IncludeAgentMetadata = parser.Strings(values, []string{}, "include_agent_metadata")
	filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization")
	filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared")
	filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", actorID)
	filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group")
	filter.UserID = parseUser(ctx, db, parser, values, "user", actorID)
	// Translate healthy filter to has-agent statuses
	// healthy:true = connected, healthy:false = disconnected or timeout
	if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid {
		if healthy.Bool {
			filter.HasAgentStatuses = append(filter.HasAgentStatuses, "connected")
		} else {
			filter.HasAgentStatuses = append(filter.HasAgentStatuses, "disconnected", "timeout")
		}
	}

	type paramMatch struct {
		name  string
		value *string
	}
	// parameter matching takes the form of:
	//	`param:<name>[=<value>]`
	// If the value is omitted, then we match on the presence of the parameter.
	// If the value is provided, then we match on the parameter and value.
	params := httpapi.ParseCustomList(parser, values, []paramMatch{}, "param", func(v string) (paramMatch, error) {
		// Ignore excess spaces
		v = strings.TrimSpace(v)
		parts := strings.Split(v, "=")
		if len(parts) == 1 {
			// Only match on the presence of the parameter
			return paramMatch{name: parts[0], value: nil}, nil
		}
		if len(parts) == 2 {
			if parts[1] == "" {
				return paramMatch{}, xerrors.Errorf("query element %q has an empty value. omit the '=' to match just on the parameter name", v)
			}
			// Match on the parameter and value
			return paramMatch{name: parts[0], value: &parts[1]}, nil
		}
		return paramMatch{}, xerrors.Errorf("query element %q can only contain 1 '='", v)
	})
	for _, p := range params {
		if p.value == nil {
			filter.HasParam = append(filter.HasParam, p.name)
			continue
		}
		filter.ParamNames = append(filter.ParamNames, p.name)
		filter.ParamValues = append(filter.ParamValues, *p.value)
	}

	parser.ErrorExcessParams(values)
	return filter, parser.Errors
}

func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query string) (database.GetTemplatesWithFilterParams, []codersdk.ValidationError) {
	// Always lowercase for all searches.
	query = strings.ToLower(query)
	values, errors := searchTerms(query, func(term string, values url.Values) error {
		// Default to the display name
		values.Add("display_name", term)
		return nil
	})
	if len(errors) > 0 {
		return database.GetTemplatesWithFilterParams{}, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter := database.GetTemplatesWithFilterParams{
		Deleted:                 parser.Boolean(values, false, "deleted"),
		OrganizationID:          parseOrganization(ctx, db, parser, values, "organization"),
		ExactName:               parser.String(values, "", "exact_name"),
		ExactDisplayName:        parser.String(values, "", "exact_display_name"),
		FuzzyName:               parser.String(values, "", "name"),
		FuzzyDisplayName:        parser.String(values, "", "display_name"),
		IDs:                     parser.UUIDs(values, []uuid.UUID{}, "ids"),
		Deprecated:              parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
		UseClassicParameterFlow: parser.NullableBoolean(values, sql.NullBool{}, "compatibility_mode"),
		AgentsAllowed:           parser.NullableBoolean(values, sql.NullBool{}, "agents-allowed"),
		AuthorID:                parser.UUID(values, uuid.Nil, "author_id"),
		AuthorUsername:          parser.String(values, "", "author"),
		HasExternalAgent:        parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"),
	}

	if filter.AuthorUsername == codersdk.Me {
		filter.AuthorID = actorID
		filter.AuthorUsername = ""
	}

	parser.ErrorExcessParams(values)
	return filter, parser.Errors
}

func AIBridgeSessions(ctx context.Context, db database.Store, query string, page codersdk.Pagination, actorID uuid.UUID, afterSessionID string) (database.ListAIBridgeSessionsParams, []codersdk.ValidationError) {
	// nolint:exhaustruct // Empty values just means "don't filter by that field".
	filter := database.ListAIBridgeSessionsParams{
		AfterSessionID: afterSessionID,
		// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
		Limit: int32(page.Limit),
		// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
		Offset: int32(page.Offset),
	}

	if query == "" {
		return filter, nil
	}

	values, errors := searchTerms(query, func(string, url.Values) error {
		// Do not specify a default search key; let's be explicit to prevent user confusion.
		return xerrors.New("no search key specified")
	})
	if len(errors) > 0 {
		return filter, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
	filter.Provider = parser.String(values, "", "provider")
	// Match interceptions by name. Do not look up ai_providers: that requires
	// AIProvider read, which session viewers do not have.
	filter.ProviderName = parser.String(values, "", "provider_name")
	filter.Model = parser.String(values, "", "model")
	filter.Client = parser.String(values, "", "client")
	filter.SessionID = parser.String(values, "", "session_id")

	// Time must be between started_after and started_before.
	filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
	filter.StartedBefore = parser.Time3339Nano(values, time.Time{}, "started_before")
	if !filter.StartedBefore.IsZero() && !filter.StartedAfter.IsZero() && !filter.StartedBefore.After(filter.StartedAfter) {
		parser.Errors = append(parser.Errors, codersdk.ValidationError{
			Field:  "started_before",
			Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
		})
	}

	parser.ErrorExcessParams(values)
	return filter, parser.Errors
}

func AIBridgeModels(query string, page codersdk.Pagination) (database.ListAIBridgeModelsParams, []codersdk.ValidationError) {
	// nolint:exhaustruct // Empty values just means "don't filter by that field".
	filter := database.ListAIBridgeModelsParams{
		// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
		Offset: int32(page.Offset),
		// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
		Limit: int32(page.Limit),
	}

	if query == "" {
		return filter, nil
	}

	values, errors := searchTerms(query, func(term string, values url.Values) error {
		// Defaults to the `model` if no `key:value` pair is provided.
		values.Add("model", term)
		return nil
	})
	if len(errors) > 0 {
		return filter, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter.Model = parser.String(values, "", "model")

	parser.ErrorExcessParams(values)
	return filter, parser.Errors
}

func AIBridgeClients(query string, page codersdk.Pagination) (database.ListAIBridgeClientsParams, []codersdk.ValidationError) {
	// nolint:exhaustruct // Empty values just means "don't filter by that field".
	filter := database.ListAIBridgeClientsParams{
		// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
		Offset: int32(page.Offset),
		// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
		Limit: int32(page.Limit),
	}

	if query == "" {
		return filter, nil
	}

	values, errors := searchTerms(query, func(term string, values url.Values) error {
		values.Add("client", term)
		return nil
	})
	if len(errors) > 0 {
		return filter, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter.Client = parser.String(values, "", "client")

	parser.ErrorExcessParams(values)
	return filter, parser.Errors
}

// Chats parses a search query for chats.
//
// Supported query parameters:
//   - title: case-insensitive title substring match via ILIKE (bare terms
//     are rejected; use title:<value> for title filtering)
//   - archived: boolean (default: false, excludes archived chats unless
//     explicitly set)
//   - has_unread: nullable boolean (filter by unread message status)
//   - status: repeated or comma-separated chat_status enum value:
//     waiting, running, error, requires_action, interrupting, or paused
//   - pr_status: repeated or comma-separated list of draft, open,
//     merged, closed, or none (no pull request)
//   - diff_url: string (matches chats whose linked diff URL equals the
//     given value, case-insensitively; URLs typically contain ':' so
//     they must be quoted, e.g. q=diff_url:"https://github.com/o/r/pull/1")
//   - pr: positive integer (exact PR number match)
//   - repo: string (case-insensitive substring match against git remote origin or URL)
//   - pr_title: string (case-insensitive PR title substring match)
//   - source: one of created_by_me, shared_with_me, or all (controls
//     ownership scope; created_by_me returns only chats the caller owns,
//     shared_with_me returns only chats shared with the caller, all returns
//     both)
//   - search: full-text search over chat content; mutually exclusive
//     with title, pr_title, and pr
func Chats(query string) (database.GetChatsParams, []codersdk.ValidationError) {
	filter := database.GetChatsParams{
		// Default to hiding archived chats and chats not owned by the caller.
		Archived:  sql.NullBool{Bool: false, Valid: true},
		OwnedOnly: true,
	}

	if query == "" {
		return filter, nil
	}

	// Lowercase the keys so they match regardless of how the caller
	// types them, but preserve value casing because some filters
	// (e.g. diff_url) may include URL path segments where case is
	// meaningful.
	values, errors := searchTerms(query, func(term string, _ url.Values) error {
		return xerrors.Errorf("unsupported search term: %q", term)
	})
	if len(errors) > 0 {
		return filter, errors
	}

	parser := httpapi.NewQueryParamParser()
	filter.Archived = parser.NullableBoolean(values, filter.Archived, "archived")
	filter.HasUnread = parser.NullableBoolean(values, filter.HasUnread, "has_unread")
	filter.ChatStatuses = httpapi.ParseCustomList(parser, values, nil, "status", func(v string) (string, error) {
		status := database.ChatStatus(strings.ToLower(strings.TrimSpace(v)))
		if !status.Valid() {
			return "", xerrors.Errorf("%q is not a valid value", v)
		}
		return string(status), nil
	})
	filter.PullRequestStatuses = httpapi.ParseCustomList(parser, values, nil, "pr_status", func(v string) (string, error) {
		normalizedPRStatus := strings.ToLower(strings.TrimSpace(v))
		switch normalizedPRStatus {
		case "draft", "open", "merged", "closed", "none":
			return normalizedPRStatus, nil
		default:
			return "", xerrors.Errorf("%q is not a valid value", v)
		}
	})
	if diffURL := parser.String(values, "", "diff_url"); diffURL != "" {
		if err := validateDiffURL(diffURL); err != nil {
			parser.Errors = append(parser.Errors, codersdk.ValidationError{
				Field:  "diff_url",
				Detail: err.Error(),
			})
		} else {
			filter.DiffURL = sql.NullString{String: diffURL, Valid: true}
		}
	}

	filter.TitleQuery = parser.String(values, "", "title")
	filter.PrTitleQuery = parser.String(values, "", "pr_title")
	filter.RepoQuery = parser.String(values, "", "repo")
	sources := httpapi.ParseCustomList(parser, values, nil, "source", func(v string) (string, error) {
		source := strings.ToLower(strings.TrimSpace(v))
		switch source {
		case "created_by_me", "shared_with_me":
			return source, nil
		default:
			return "", xerrors.Errorf("%q is not a valid value", v)
		}
	})
	if len(sources) > 0 {
		hasCreatedByMe := slices.Contains(sources, "created_by_me")
		hasSharedWithMe := slices.Contains(sources, "shared_with_me")

		switch {
		case hasCreatedByMe && hasSharedWithMe:
			filter.OwnedOnly = true
			filter.SharedOnly = true
		case hasSharedWithMe:
			filter.OwnedOnly = false
			filter.SharedOnly = true
		default:
			filter.OwnedOnly = true
			filter.SharedOnly = false
		}
	}

	// pr: requires a positive integer.
	if prStr := parser.String(values, "", "pr"); prStr != "" {
		n, err := strconv.ParseInt(prStr, 10, 32)
		if err != nil || n <= 0 {
			parser.Errors = append(parser.Errors, codersdk.ValidationError{
				Field:  "pr",
				Detail: fmt.Sprintf("%q is not a valid positive integer", prStr),
			})
		} else {
			filter.PrNumber = int32(n)
		}
	}

	if values.Has("search") {
		parser.RequiredNotEmpty("search")
		if search := parser.String(values, "", "search"); search != "" {
			var conflicts []string
			if filter.TitleQuery != "" {
				conflicts = append(conflicts, `"title"`)
			}
			if filter.PrTitleQuery != "" {
				conflicts = append(conflicts, `"pr_title"`)
			}
			if filter.PrNumber != 0 {
				conflicts = append(conflicts, `"pr"`)
			}
			if len(conflicts) > 0 {
				parser.Errors = append(parser.Errors, codersdk.ValidationError{
					Field:  "search",
					Detail: fmt.Sprintf(`"search" cannot be combined with %s`, strings.Join(conflicts, ", ")),
				})
			} else {
				filter.Search = search
			}
		}
	}

	parser.ErrorExcessParams(values)
	return filter, parser.Errors
}

// validateDiffURL checks that the value is a syntactically valid HTTP(S)
// URL. The check is intentionally forge-agnostic because the diff URL on
// a chat may point to a pull request, merge request, branch page, etc.
func validateDiffURL(raw string) error {
	u, err := url.Parse(raw)
	if err != nil {
		return xerrors.Errorf("diff_url is not a valid URL: %w", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return xerrors.Errorf("diff_url must use http or https scheme, got %q", u.Scheme)
	}
	if u.Host == "" {
		return xerrors.New("diff_url must include a host")
	}
	return nil
}

func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
	searchValues := make(url.Values)

	// Because we do this in 2 passes, we want to maintain quotes on the first
	// pass. Further splitting occurs on the second pass and quotes will be
	// dropped.
	tokens := splitQueryParameterByDelimiter(query, ' ', true)
	elements := processTokens(tokens)
	for _, element := range elements {
		if strings.HasPrefix(element, ":") || strings.HasSuffix(element, ":") {
			return nil, []codersdk.ValidationError{
				{
					Field:  "q",
					Detail: fmt.Sprintf("Query element %q cannot start or end with ':'", element),
				},
			}
		}
		parts := splitQueryParameterByDelimiter(element, ':', false)
		switch len(parts) {
		case 1:
			// No key:value pair. Use default behavior.
			err := defaultKey(element, searchValues)
			if err != nil {
				return nil, []codersdk.ValidationError{
					{Field: "q", Detail: err.Error()},
				}
			}
		case 2:
			searchValues.Add(strings.ToLower(parts[0]), parts[1])
		default:
			return nil, []codersdk.ValidationError{
				{
					Field:  "q",
					Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element),
				},
			}
		}
	}

	return searchValues, nil
}

func parseOrganization(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
	return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
		if v == "" {
			return uuid.Nil, nil
		}
		organizationID, err := uuid.Parse(v)
		if err == nil {
			return organizationID, nil
		}
		organization, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
			Name: v, Deleted: false,
		})
		if err != nil {
			return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", v)
		}
		return organization.ID, nil
	})
}

func parseUser(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string, actorID uuid.UUID) uuid.UUID {
	return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
		if v == "" {
			return uuid.Nil, nil
		}
		if v == codersdk.Me && actorID != uuid.Nil {
			return actorID, nil
		}
		userID, err := uuid.Parse(v)
		if err == nil {
			return userID, nil
		}
		user, err := db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
			Username: v,
		})
		if err != nil {
			return uuid.Nil, xerrors.Errorf("user %q either does not exist, or you are unauthorized to view them", v)
		}
		return user.ID, nil
	})
}

// Parse a group filter value into a group UUID.
// Supported formats:
//   - <group-uuid>
//   - <organization-name>/<group-name>
//   - <group-name> (resolved in the default organization)
func parseGroup(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID {
	return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
		if v == "" {
			return uuid.Nil, nil
		}
		groupID, err := uuid.Parse(v)
		if err == nil {
			return groupID, nil
		}

		var groupName string
		var org database.Organization
		parts := strings.Split(v, "/")
		switch len(parts) {
		case 1:
			dbOrg, err := db.GetDefaultOrganization(ctx)
			if err != nil {
				return uuid.Nil, xerrors.New("fetching default organization")
			}
			org = dbOrg
			groupName = parts[0]
		case 2:
			orgName := parts[0]
			if err := codersdk.NameValid(orgName); err != nil {
				return uuid.Nil, xerrors.Errorf("invalid organization name %w", err)
			}
			dbOrg, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
				Name: orgName,
			})
			if err != nil {
				return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", orgName)
			}
			org = dbOrg

			groupName = parts[1]

		default:
			return uuid.Nil, xerrors.New("invalid organization or group name, the filter must be in the pattern of <organization name>/<group name>")
		}

		if err := codersdk.GroupNameValid(groupName); err != nil {
			return uuid.Nil, xerrors.Errorf("invalid group name %w", err)
		}

		group, err := db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{
			OrganizationID: org.ID,
			Name:           groupName,
		})
		if err != nil {
			return uuid.Nil, xerrors.Errorf("group %q either does not exist, does not belong to the organization %q, or you are unauthorized to view it", groupName, org.Name)
		}
		return group.ID, nil
	})
}

// splitQueryParameterByDelimiter takes a query string and splits it into the individual elements
// of the query. Each element is separated by a delimiter. All quoted strings are
// kept as a single element.
//
// Although all our names cannot have spaces, that is a validation error.
// We should still parse the quoted string as a single value so that validation
// can properly fail on the space. If we do not, a value of `template:"my name"`
// will search `template:"my name:name"`, which produces an empty list instead of
// an error.
// nolint:revive
func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes bool) []string {
	quoted := false
	parts := strings.FieldsFunc(query, func(r rune) bool {
		if r == '"' {
			quoted = !quoted
		}
		return !quoted && r == delimiter
	})
	if !maintainQuotes {
		for i, part := range parts {
			parts[i] = strings.Trim(part, "\"")
		}
	}

	return parts
}

// processTokens takes the split tokens and groups them based on a delimiter (':').
// Tokens without a delimiter present are joined to support searching with spaces.
//
//	Example Input: ['deprecated:false', 'test', 'template']
//	Example Output: ['deprecated:false', 'test template']
func processTokens(tokens []string) []string {
	var results []string
	var nonFieldTerms []string
	for _, token := range tokens {
		if strings.Contains(token, string(':')) {
			results = append(results, token)
		} else {
			nonFieldTerms = append(nonFieldTerms, token)
		}
	}
	if len(nonFieldTerms) > 0 {
		results = append(results, strings.Join(nonFieldTerms, " "))
	}
	return results
}