356bf1a5ba
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Add an exact (strict) email filter to the /users list (UserFilter.EmailExact → a kind='email' identity match) with a search input, and an 'Erase email' action on the user card that deletes the bound email identity and its pending confirmations, freeing the address. It refuses to remove the account's only identity (ErrLastIdentity), which would leave it unreachable. Integration tests for both.
156 lines
5.7 KiB
Go
156 lines
5.7 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// UserListItem is the admin user-list projection: a small subset of the account plus
|
|
// whether it is a robot (derived from its identities), so the console can label the kind
|
|
// without a per-row identity query.
|
|
type UserListItem struct {
|
|
ID uuid.UUID
|
|
DisplayName string
|
|
PreferredLanguage string
|
|
IsGuest bool
|
|
IsRobot bool
|
|
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
|
// as a badge in the console list.
|
|
FlaggedHighRateAt time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
|
|
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
|
|
// one char) matched case-insensitively against the display name / any identity's external
|
|
// id; EmailExact is a strict (exact) match against an account's email identity. An empty
|
|
// value means no filter on that field.
|
|
type UserFilter struct {
|
|
Robots bool
|
|
NameMask string
|
|
ExternalIDMask string
|
|
EmailExact string
|
|
}
|
|
|
|
// robotExists is the correlated subquery testing whether account a is a robot.
|
|
const robotExists = `EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'robot')`
|
|
|
|
// IsRobot reports whether the account is a robot pool member (it carries a robot
|
|
// identity). The admin console uses it to label a game's robot seats.
|
|
func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
|
var ok bool
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM backend.identities WHERE account_id = $1 AND kind = 'robot')`,
|
|
accountID).Scan(&ok)
|
|
if err != nil {
|
|
return false, fmt.Errorf("account: is-robot %s: %w", accountID, err)
|
|
}
|
|
return ok, nil
|
|
}
|
|
|
|
// userListWhere builds the shared WHERE clause and its positional args (from $1).
|
|
func userListWhere(f UserFilter) (string, []any) {
|
|
args := []any{f.Robots}
|
|
where := robotExists + ` = $1`
|
|
if name := LikePattern(f.NameMask); name != "" {
|
|
args = append(args, name)
|
|
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
|
}
|
|
if ext := LikePattern(f.ExternalIDMask); ext != "" {
|
|
args = append(args, ext)
|
|
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
|
|
}
|
|
if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" {
|
|
args = append(args, email)
|
|
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args))
|
|
}
|
|
return where, args
|
|
}
|
|
|
|
// ListUsers returns the filtered admin user list, newest first, paginated.
|
|
func (s *Store) ListUsers(ctx context.Context, f UserFilter, limit, offset int) ([]UserListItem, error) {
|
|
where, args := userListWhere(f)
|
|
q := `SELECT a.account_id, a.display_name, a.preferred_language, a.is_guest, a.flagged_high_rate_at, a.created_at, ` + robotExists + ` AS is_robot
|
|
FROM backend.accounts a WHERE ` + where +
|
|
fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
|
args = append(args, limit, offset)
|
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: list users: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []UserListItem
|
|
for rows.Next() {
|
|
var it UserListItem
|
|
var flagged sql.NullTime
|
|
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot); err != nil {
|
|
return nil, fmt.Errorf("account: scan user: %w", err)
|
|
}
|
|
if flagged.Valid {
|
|
it.FlaggedHighRateAt = flagged.Time
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// FlaggedAccount is one row of the console's high-rate review queue.
|
|
type FlaggedAccount struct {
|
|
ID uuid.UUID
|
|
DisplayName string
|
|
FlaggedHighRateAt time.Time
|
|
}
|
|
|
|
// flaggedListCap bounds the console's flagged-account list; the operator clears
|
|
// flags as they are reviewed, so the queue stays short in practice.
|
|
const flaggedListCap = 200
|
|
|
|
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
|
|
// recently flagged first.
|
|
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT account_id, display_name, flagged_high_rate_at
|
|
FROM backend.accounts WHERE flagged_high_rate_at IS NOT NULL
|
|
ORDER BY flagged_high_rate_at DESC LIMIT $1`, flaggedListCap)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("account: list flagged: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []FlaggedAccount
|
|
for rows.Next() {
|
|
var fa FlaggedAccount
|
|
if err := rows.Scan(&fa.ID, &fa.DisplayName, &fa.FlaggedHighRateAt); err != nil {
|
|
return nil, fmt.Errorf("account: scan flagged: %w", err)
|
|
}
|
|
out = append(out, fa)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CountUsers counts the filtered admin user list, for pagination.
|
|
func (s *Store) CountUsers(ctx context.Context, f UserFilter) (int, error) {
|
|
where, args := userListWhere(f)
|
|
var n int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM backend.accounts a WHERE `+where, args...).Scan(&n); err != nil {
|
|
return 0, fmt.Errorf("account: count users: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// LikePattern converts a glob mask ('*' any run, '?' one char) to an ILIKE pattern,
|
|
// escaping the SQL wildcards already in the input first. An empty/blank mask returns "".
|
|
func LikePattern(mask string) string {
|
|
mask = strings.TrimSpace(mask)
|
|
if mask == "" {
|
|
return ""
|
|
}
|
|
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(mask)
|
|
escaped = strings.ReplaceAll(escaped, "*", "%")
|
|
return strings.ReplaceAll(escaped, "?", "_")
|
|
}
|