From 4cc37e5760e1375c0c0b959138c9153da047535f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 14:15:02 +0200 Subject: [PATCH] fix(admin): unified user search across live + deleted, incl. the retention journal 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. --- backend/internal/account/userlist.go | 50 +++++++++++++------ .../adminconsole/templates/pages/users.gohtml | 2 +- backend/internal/adminconsole/views.go | 1 + backend/internal/inttest/delete_test.go | 25 +++++++++- .../internal/server/handlers_admin_console.go | 9 ++-- 5 files changed, 66 insertions(+), 21 deletions(-) diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go index c9b7acd..4f3b6fc 100644 --- a/backend/internal/account/userlist.go +++ b/backend/internal/account/userlist.go @@ -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 { diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml index b99bee4..f6499ea 100644 --- a/backend/internal/adminconsole/templates/pages/users.gohtml +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -19,7 +19,7 @@ {{range .Items}} {{.ID}} -{{.DisplayName}}{{if .Guest}} guest{{end}}{{if .FlaggedHighRate}} high-rate{{end}} +{{.DisplayName}}{{if .Deleted}} deleted{{end}}{{if .Guest}} guest{{end}}{{if .FlaggedHighRate}} high-rate{{end}} {{.Kind}} {{.Language}} {{.CreatedAt}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 1825d48..deddfe2 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -76,6 +76,7 @@ type UserRow struct { Kind string Language string Guest bool + Deleted bool FlaggedHighRate bool CreatedAt string HasMoveStats bool diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 94760f6..802e0e4 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -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 diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 6e72a08..48076fe 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -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)