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 {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||
<td>{{.DisplayName}}{{if .Deleted}} <span class="pill">deleted</span>{{end}}{{if .Guest}} <span class="pill">guest</span>{{end}}{{if .FlaggedHighRate}} <span class="pill">high-rate</span>{{end}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Language}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
|
||||
@@ -76,6 +76,7 @@ type UserRow struct {
|
||||
Kind string
|
||||
Language string
|
||||
Guest bool
|
||||
Deleted bool
|
||||
FlaggedHighRate bool
|
||||
CreatedAt string
|
||||
HasMoveStats bool
|
||||
|
||||
@@ -155,10 +155,15 @@ func TestUserListDeletedFilter(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("provision live: %v", err)
|
||||
}
|
||||
gone, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Gone", "")
|
||||
goneTg := "tg-" + uuid.NewString()
|
||||
gone, _, err := store.ProvisionTelegram(ctx, goneTg, "en", "", "Gone", "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision gone: %v", err)
|
||||
}
|
||||
goneEmail := "gone-" + uuid.NewString() + "@example.com"
|
||||
if err := store.AttachIdentity(ctx, gone.ID, account.KindEmail, goneEmail, true); err != nil {
|
||||
t.Fatalf("attach gone email: %v", err)
|
||||
}
|
||||
if err := deleter.AnonymizeAndTombstone(ctx, gone.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
@@ -183,6 +188,24 @@ func TestUserListDeletedFilter(t *testing.T) {
|
||||
if listHasID(deleted, live.ID) {
|
||||
t.Error("a live account must not appear in the Deleted list")
|
||||
}
|
||||
|
||||
// A search spans both lists and reaches the retention journal: a deleted account is
|
||||
// still found by the email and external id it held (both moved to retained_identities
|
||||
// on deletion, out of the live identities table).
|
||||
byEmail, err := store.ListUsers(ctx, account.UserFilter{EmailExact: goneEmail}, 5000, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("search by email: %v", err)
|
||||
}
|
||||
if !listHasID(byEmail, gone.ID) {
|
||||
t.Error("a deleted account must be found by the email it held (retention journal)")
|
||||
}
|
||||
byExt, err := store.ListUsers(ctx, account.UserFilter{ExternalIDMask: goneTg}, 5000, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("search by external id: %v", err)
|
||||
}
|
||||
if !listHasID(byExt, gone.ID) {
|
||||
t.Error("a deleted account must be found by the external id it held (retention journal)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmCodeClearsGuest: confirming an email on a guest via ConfirmCode promotes it to
|
||||
|
||||
@@ -170,14 +170,17 @@ func (s *Server) consoleUsers(c *gin.Context) {
|
||||
ids := make([]uuid.UUID, 0, len(items))
|
||||
for _, it := range items {
|
||||
kind := "registered"
|
||||
if it.IsRobot {
|
||||
switch {
|
||||
case it.IsRobot:
|
||||
kind = "robot"
|
||||
} else if it.IsGuest {
|
||||
case it.IsDeleted:
|
||||
kind = "deleted"
|
||||
case it.IsGuest:
|
||||
kind = "guest"
|
||||
}
|
||||
view.Items = append(view.Items, adminconsole.UserRow{
|
||||
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
|
||||
Language: it.PreferredLanguage, Guest: it.IsGuest,
|
||||
Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted,
|
||||
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
|
||||
})
|
||||
ids = append(ids, it.ID)
|
||||
|
||||
Reference in New Issue
Block a user