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}}