| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package search |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | ) |
| 9 | |
| 10 | // SearchUsers runs a user search. No visibility predicate — user |
| 11 | // profiles are public by definition; suspended/deleted accounts are |
| 12 | // excluded so they don't taint search results (matches the spec |
| 13 | // pitfall: "search on suspended user content"). |
| 14 | func SearchUsers(ctx context.Context, deps Deps, q ParsedQuery, limit, offset int) ([]UserResult, int64, error) { |
| 15 | if !q.HasContent() { |
| 16 | return nil, 0, ErrEmptyQuery |
| 17 | } |
| 18 | tsText, tsCtor, hasFTS := tsQueryBindAndCtor(q) |
| 19 | if !hasFTS { |
| 20 | // Operators don't apply to user search; with no FTS payload |
| 21 | // there's nothing to match. |
| 22 | return nil, 0, nil |
| 23 | } |
| 24 | |
| 25 | args := []any{tsText, limit, offset} |
| 26 | queryStr := fmt.Sprintf(` |
| 27 | SELECT u.id, u.username::text, u.display_name, coalesce(u.bio, ''), |
| 28 | ts_rank_cd(s.tsv, %[1]s('shithub_search', $1)) AS rank |
| 29 | FROM users_search s |
| 30 | JOIN users u ON u.id = s.user_id |
| 31 | WHERE s.tsv @@ %[1]s('shithub_search', $1) |
| 32 | AND u.suspended_at IS NULL |
| 33 | AND u.deleted_at IS NULL |
| 34 | ORDER BY rank DESC, u.username |
| 35 | LIMIT $2 OFFSET $3 |
| 36 | `, tsCtor) |
| 37 | rows, err := deps.Pool.Query(ctx, queryStr, args...) |
| 38 | if err != nil { |
| 39 | return nil, 0, fmt.Errorf("search users: %w", err) |
| 40 | } |
| 41 | defer rows.Close() |
| 42 | out := make([]UserResult, 0, limit) |
| 43 | for rows.Next() { |
| 44 | var r UserResult |
| 45 | if err := rows.Scan(&r.ID, &r.Username, &r.DisplayName, &r.Bio, &r.Rank); err != nil { |
| 46 | return nil, 0, err |
| 47 | } |
| 48 | out = append(out, r) |
| 49 | } |
| 50 | if err := rows.Err(); err != nil { |
| 51 | return nil, 0, err |
| 52 | } |
| 53 | |
| 54 | countQuery := fmt.Sprintf(` |
| 55 | SELECT count(*) FROM users_search s |
| 56 | JOIN users u ON u.id = s.user_id |
| 57 | WHERE s.tsv @@ %[1]s('shithub_search', $1) |
| 58 | AND u.suspended_at IS NULL |
| 59 | AND u.deleted_at IS NULL |
| 60 | `, tsCtor) |
| 61 | var total int64 |
| 62 | if err := deps.Pool.QueryRow(ctx, countQuery, tsText).Scan(&total); err != nil { |
| 63 | return nil, 0, fmt.Errorf("count users: %w", err) |
| 64 | } |
| 65 | return out, total, nil |
| 66 | } |
| 67 |