fix(admin): unified user search across live + deleted, incl. the retention journal
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 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
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 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
The email/name/external-id search was scoped to one tab and only looked in the live identities table, so a deleted account (whose credentials moved to retained_identities on deletion) could not be found by the email/id it held. Now a people search spans live and deleted accounts in one query (robots only on the Robots tab) and also matches the retention journal and the retained real name; results carry a 'deleted' badge. Integration test: a deleted account is found by its held email and external id.
This commit is contained in:
@@ -19,6 +19,9 @@ type UserListItem struct {
|
||||
PreferredLanguage string
|
||||
IsGuest bool
|
||||
IsRobot bool
|
||||
// IsDeleted marks a tombstoned account (deleted_at set), shown as a badge — a search
|
||||
// spans both lists, so a result can be either live or deleted.
|
||||
IsDeleted bool
|
||||
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
|
||||
// as a badge in the console list.
|
||||
FlaggedHighRateAt time.Time
|
||||
@@ -55,27 +58,42 @@ func (s *Store) IsRobot(ctx context.Context, accountID uuid.UUID) (bool, error)
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// userListWhere builds the shared WHERE clause and its positional args (from $1).
|
||||
// userListWhere builds the shared WHERE clause and its positional args (from $1). On the
|
||||
// Robots tab it lists/searches robots only. Otherwise a search (any of the name /
|
||||
// external-id / email filters) spans live and deleted people alike — never robots — so the
|
||||
// operator finds a match from one query regardless of the People / Deleted tab; the search
|
||||
// also looks in the retention journal, so a deleted account is still found by the email /
|
||||
// external id it held (those rows moved from identities to retained_identities on deletion)
|
||||
// and by its retained real name. With no search, the People / Deleted tab scope applies.
|
||||
func userListWhere(f UserFilter) (string, []any) {
|
||||
args := []any{f.Robots}
|
||||
where := robotExists + ` = $1`
|
||||
// The Deleted scope shows tombstoned accounts; every other scope hides them.
|
||||
if f.Deleted {
|
||||
where += ` AND a.deleted_at IS NOT NULL`
|
||||
} else {
|
||||
where += ` AND a.deleted_at IS NULL`
|
||||
name := LikePattern(f.NameMask)
|
||||
ext := LikePattern(f.ExternalIDMask)
|
||||
email := strings.ToLower(strings.TrimSpace(f.EmailExact))
|
||||
searching := name != "" || ext != "" || email != ""
|
||||
|
||||
var args []any
|
||||
var where string
|
||||
switch {
|
||||
case f.Robots:
|
||||
where = robotExists + ` = true`
|
||||
case searching:
|
||||
where = robotExists + ` = false`
|
||||
case f.Deleted:
|
||||
where = robotExists + ` = false AND a.deleted_at IS NOT NULL`
|
||||
default:
|
||||
where = robotExists + ` = false AND a.deleted_at IS NULL`
|
||||
}
|
||||
if name := LikePattern(f.NameMask); name != "" {
|
||||
if name != "" {
|
||||
args = append(args, name)
|
||||
where += fmt.Sprintf(` AND a.display_name ILIKE $%d ESCAPE '\'`, len(args))
|
||||
where += fmt.Sprintf(` AND (a.display_name ILIKE $%d ESCAPE '\' OR a.deleted_display_name ILIKE $%d ESCAPE '\')`, len(args), len(args))
|
||||
}
|
||||
if ext := LikePattern(f.ExternalIDMask); ext != "" {
|
||||
if 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))
|
||||
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 '\') OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.external_id ILIKE $%d ESCAPE '\'))`, len(args), len(args))
|
||||
}
|
||||
if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" {
|
||||
if 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))
|
||||
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) OR EXISTS (SELECT 1 FROM backend.retained_identities r WHERE r.account_id = a.account_id AND r.kind = 'email' AND r.external_id = $%d))`, len(args), len(args))
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
@@ -83,7 +101,7 @@ func userListWhere(f UserFilter) (string, []any) {
|
||||
// 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
|
||||
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, (a.deleted_at IS NOT NULL) AS is_deleted
|
||||
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)
|
||||
@@ -96,7 +114,7 @@ FROM backend.accounts a WHERE ` + where +
|
||||
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 {
|
||||
if err := rows.Scan(&it.ID, &it.DisplayName, &it.PreferredLanguage, &it.IsGuest, &flagged, &it.CreatedAt, &it.IsRobot, &it.IsDeleted); err != nil {
|
||||
return nil, fmt.Errorf("account: scan user: %w", err)
|
||||
}
|
||||
if flagged.Valid {
|
||||
|
||||
Reference in New Issue
Block a user