8881214213
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
150 lines
5.3 KiB
Go
150 lines
5.3 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. An empty mask means no filter on that field.
|
|
type UserFilter struct {
|
|
Robots bool
|
|
NameMask string
|
|
ExternalIDMask 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))
|
|
}
|
|
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, "?", "_")
|
|
}
|