satellite/admin: list users pending deletion

This change adds an endpoint to the admin API and UI to get a list of
users pending deletion and have no unpaid invoice.

Issue: #6410

Change-Id: I906dbf9eee9e7469e45f0c622a891867bf0cc201
This commit is contained in:
Wilfred Asomani 2023-10-23 16:27:00 +00:00 committed by Storj Robot
parent 405491e8d0
commit 513c3cc632
9 changed files with 409 additions and 1 deletions

View File

@ -24,6 +24,7 @@ Requires setting `Authorization` header for requests.
* [PUT /api/users/{user-email}/violation-freeze](#put-apiusersuser-emailviolation-freeze)
* [DELETE /api/users/{user-email}/violation-freeze](#delete-apiusersuser-emailviolation-freeze)
* [DELETE /api/users/{user-email}/billing-warning](#delete-apiusersuser-emailbilling-warning)
* [GET /api/users/pending-deletion](#get-apiuserspending-deletion)
* [PATCH /api/users/{user-email}/geofence](#patch-apiusersuser-emailgeofence)
* [DELETE /api/users/{user-email}/geofence](#delete-apiusersuser-emailgeofence)
* [OAuth Client Management](#oauth-client-management)
@ -199,6 +200,12 @@ User status is set back to Active. This is the only way to exit the violation fr
Removes the billing warning status from a user's account.
#### GET /api/users/pending-deletion
Returns a limited list of users pending deletion and have no unpaid invoices.
Required parameters: `limit` and `page`.
Example: `/api/users/pending-deletion?limit=10&page=1`
#### PATCH /api/users/{user-email}/geofence
Sets the account level geofence for the user.
@ -454,7 +461,7 @@ A successful response body:
},
"project": {
"id": "12345678-1234-1234-1234-123456789abc",
"name": "My Project",
"name": "My Project"
},
"owner": {
"id": "12345678-1234-1234-1234-123456789abc",

View File

@ -174,6 +174,7 @@ func NewServer(
limitUpdateAPI.HandleFunc("/users/{useremail}/billing-warning", server.billingUnWarnUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationFreezeUser).Methods("PUT")
limitUpdateAPI.HandleFunc("/users/{useremail}/violation-freeze", server.violationUnfreezeUser).Methods("DELETE")
limitUpdateAPI.HandleFunc("/users/pending-deletion", server.usersPendingDeletion).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.getProjectLimit).Methods("GET")
limitUpdateAPI.HandleFunc("/projects/{project}/limit", server.putProjectLimit).Methods("PUT", "POST")

View File

@ -343,6 +343,17 @@ export class Admin {
return this.fetch('GET', `users/${email}`);
}
},
{
name: 'get users pending deletion',
desc: 'Get the information of a users pending deletion and have no unpaid invoices',
params: [
['Limit', new InputText('number', true)],
['Page', new InputText('number', true)]
],
func: async (limit: number, page: number): Promise<Record<string, unknown>> => {
return this.fetch('GET', `users-pending-deletion?limit=${limit}&page=${page}`);
}
},
{
name: 'get user limits',
desc: 'Get the current limits for a user',

View File

@ -206,6 +206,96 @@ func (server *Server) userInfo(w http.ResponseWriter, r *http.Request) {
sendJSONData(w, http.StatusOK, data)
}
func (server *Server) usersPendingDeletion(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
type User struct {
ID uuid.UUID `json:"id"`
FullName string `json:"fullName"`
Email string `json:"email"`
}
query := r.URL.Query()
limitParam := query.Get("limit")
if limitParam == "" {
sendJSONError(w, "Bad request", "parameter 'limit' can't be empty", http.StatusBadRequest)
return
}
limit, err := strconv.ParseUint(limitParam, 10, 32)
if err != nil {
sendJSONError(w, "Bad request", err.Error(), http.StatusBadRequest)
return
}
pageParam := query.Get("page")
if pageParam == "" {
sendJSONError(w, "Bad request", "parameter 'page' can't be empty", http.StatusBadRequest)
return
}
page, err := strconv.ParseUint(pageParam, 10, 32)
if err != nil {
sendJSONError(w, "Bad request", err.Error(), http.StatusBadRequest)
return
}
var sendingPage struct {
Users []User `json:"users"`
PageCount uint `json:"pageCount"`
CurrentPage uint `json:"currentPage"`
TotalCount uint64 `json:"totalCount"`
HasMore bool `json:"hasMore"`
}
usersPage, err := server.db.Console().Users().GetByStatus(
ctx, console.PendingDeletion, console.UserCursor{
Limit: uint(limit),
Page: uint(page),
},
)
if err != nil {
sendJSONError(w, "failed retrieving a usersPage of users", err.Error(), http.StatusInternalServerError)
return
}
sendingPage.PageCount = usersPage.PageCount
sendingPage.CurrentPage = usersPage.CurrentPage
sendingPage.TotalCount = usersPage.TotalCount
sendingPage.Users = make([]User, 0, len(usersPage.Users))
if sendingPage.PageCount > sendingPage.CurrentPage {
sendingPage.HasMore = true
}
for _, user := range usersPage.Users {
invoices, err := server.payments.Invoices().ListFailed(ctx, &user.ID)
if err != nil {
sendJSONError(w, "getting invoices failed",
err.Error(), http.StatusInternalServerError)
return
}
if len(invoices) != 0 {
sendingPage.TotalCount--
continue
}
sendingPage.Users = append(sendingPage.Users, User{
ID: user.ID,
FullName: user.FullName,
Email: user.Email,
})
}
data, err := json.Marshal(sendingPage)
if err != nil {
sendJSONError(w, "json encoding failed",
err.Error(), http.StatusInternalServerError)
return
}
sendJSONData(w, http.StatusOK, data)
}
func (server *Server) userLimits(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -30,6 +30,8 @@ type Users interface {
UpdateFailedLoginCountAndExpiration(ctx context.Context, failedLoginPenalty *float64, id uuid.UUID) error
// GetByEmailWithUnverified is a method for querying users by email from the database.
GetByEmailWithUnverified(ctx context.Context, email string) (*User, []User, error)
// GetByStatus is a method for querying user by status from the database.
GetByStatus(ctx context.Context, status UserStatus, cursor UserCursor) (*UsersPage, error)
// GetByEmail is a method for querying user by verified email from the database.
GetByEmail(ctx context.Context, email string) (*User, error)
// Insert is a method for inserting user into the database.
@ -66,6 +68,24 @@ type UserInfo struct {
ShortName string `json:"shortName"`
}
// UserCursor holds info for user info cursor pagination.
type UserCursor struct {
Limit uint `json:"limit"`
Page uint `json:"page"`
}
// UsersPage represent user info page result.
type UsersPage struct {
Users []User `json:"users"`
Limit uint `json:"limit"`
Offset uint64 `json:"offset"`
PageCount uint `json:"pageCount"`
CurrentPage uint `json:"currentPage"`
TotalCount uint64 `json:"totalCount"`
}
// IsValid checks UserInfo validity and returns error describing whats wrong.
// The returned error has the class ErrValiation.
func (user *UserInfo) IsValid() error {

View File

@ -336,6 +336,53 @@ func TestGetUserByEmail(t *testing.T) {
})
}
func TestGetUsersByStatus(t *testing.T) {
satellitedbtest.Run(t, func(ctx *testcontext.Context, t *testing.T, db satellite.DB) {
usersRepo := db.Console().Users()
inactiveUser := console.User{
ID: testrand.UUID(),
FullName: "Inactive User",
Email: email,
PasswordHash: []byte("123a123"),
}
_, err := usersRepo.Insert(ctx, &inactiveUser)
require.NoError(t, err)
activeUser := console.User{
ID: testrand.UUID(),
FullName: "Active User",
Email: email,
Status: console.Active,
PasswordHash: []byte("123a123"),
}
_, err = usersRepo.Insert(ctx, &activeUser)
require.NoError(t, err)
// Required to set the active status.
err = usersRepo.Update(ctx, activeUser.ID, console.UpdateUserRequest{
Status: &activeUser.Status,
})
require.NoError(t, err)
cursor := console.UserCursor{
Limit: 50,
Page: 1,
}
usersPage, err := usersRepo.GetByStatus(ctx, console.Inactive, cursor)
require.NoError(t, err)
require.Lenf(t, usersPage.Users, 1, "expected 1 inactive user")
require.Equal(t, inactiveUser.ID, usersPage.Users[0].ID)
usersPage, err = usersRepo.GetByStatus(ctx, console.Active, cursor)
require.NoError(t, err)
require.Lenf(t, usersPage.Users, 1, "expected 1 active user")
require.Equal(t, activeUser.ID, usersPage.Users[0].ID)
})
}
func TestGetUnverifiedNeedingReminder(t *testing.T) {
testplanet.Run(t, testplanet.Config{
Reconfigure: testplanet.Reconfigure{

View File

@ -11992,6 +11992,12 @@ type CustomerId_Row struct {
CustomerId string
}
type Id_Email_FullName_Row struct {
Id []byte
Email string
FullName string
}
type Id_PieceCount_Row struct {
Id []byte
PieceCount int64
@ -16871,6 +16877,77 @@ func (obj *pgxImpl) Get_User_ProjectStorageLimit_User_ProjectBandwidthLimit_User
}
func (obj *pgxImpl) Count_User_By_Status(ctx context.Context,
user_status User_Status_Field) (
count int64, err error) {
defer mon.Task()(&ctx)(&err)
var __embed_stmt = __sqlbundle_Literal("SELECT COUNT(*) FROM users WHERE users.status = ?")
var __values []interface{}
__values = append(__values, user_status.value())
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
obj.logStmt(__stmt, __values...)
err = obj.queryRowContext(ctx, __stmt, __values...).Scan(&count)
if err != nil {
return 0, obj.makeErr(err)
}
return count, nil
}
func (obj *pgxImpl) Limited_User_Id_User_Email_User_FullName_By_Status(ctx context.Context,
user_status User_Status_Field,
limit int, offset int64) (
rows []*Id_Email_FullName_Row, err error) {
defer mon.Task()(&ctx)(&err)
var __embed_stmt = __sqlbundle_Literal("SELECT users.id, users.email, users.full_name FROM users WHERE users.status = ? LIMIT ? OFFSET ?")
var __values []interface{}
__values = append(__values, user_status.value())
__values = append(__values, limit, offset)
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
obj.logStmt(__stmt, __values...)
for {
rows, err = func() (rows []*Id_Email_FullName_Row, err error) {
__rows, err := obj.driver.QueryContext(ctx, __stmt, __values...)
if err != nil {
return nil, err
}
defer __rows.Close()
for __rows.Next() {
row := &Id_Email_FullName_Row{}
err = __rows.Scan(&row.Id, &row.Email, &row.FullName)
if err != nil {
return nil, err
}
rows = append(rows, row)
}
err = __rows.Err()
if err != nil {
return nil, err
}
return rows, nil
}()
if err != nil {
if obj.shouldRetry(err) {
continue
}
return nil, obj.makeErr(err)
}
return rows, nil
}
}
func (obj *pgxImpl) All_WebappSession_By_UserId(ctx context.Context,
webapp_session_user_id WebappSession_UserId_Field) (
rows []*WebappSession, err error) {
@ -25188,6 +25265,77 @@ func (obj *pgxcockroachImpl) Get_User_ProjectStorageLimit_User_ProjectBandwidthL
}
func (obj *pgxcockroachImpl) Count_User_By_Status(ctx context.Context,
user_status User_Status_Field) (
count int64, err error) {
defer mon.Task()(&ctx)(&err)
var __embed_stmt = __sqlbundle_Literal("SELECT COUNT(*) FROM users WHERE users.status = ?")
var __values []interface{}
__values = append(__values, user_status.value())
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
obj.logStmt(__stmt, __values...)
err = obj.queryRowContext(ctx, __stmt, __values...).Scan(&count)
if err != nil {
return 0, obj.makeErr(err)
}
return count, nil
}
func (obj *pgxcockroachImpl) Limited_User_Id_User_Email_User_FullName_By_Status(ctx context.Context,
user_status User_Status_Field,
limit int, offset int64) (
rows []*Id_Email_FullName_Row, err error) {
defer mon.Task()(&ctx)(&err)
var __embed_stmt = __sqlbundle_Literal("SELECT users.id, users.email, users.full_name FROM users WHERE users.status = ? LIMIT ? OFFSET ?")
var __values []interface{}
__values = append(__values, user_status.value())
__values = append(__values, limit, offset)
var __stmt = __sqlbundle_Render(obj.dialect, __embed_stmt)
obj.logStmt(__stmt, __values...)
for {
rows, err = func() (rows []*Id_Email_FullName_Row, err error) {
__rows, err := obj.driver.QueryContext(ctx, __stmt, __values...)
if err != nil {
return nil, err
}
defer __rows.Close()
for __rows.Next() {
row := &Id_Email_FullName_Row{}
err = __rows.Scan(&row.Id, &row.Email, &row.FullName)
if err != nil {
return nil, err
}
rows = append(rows, row)
}
err = __rows.Err()
if err != nil {
return nil, err
}
return rows, nil
}()
if err != nil {
if obj.shouldRetry(err) {
continue
}
return nil, obj.makeErr(err)
}
return rows, nil
}
}
func (obj *pgxcockroachImpl) All_WebappSession_By_UserId(ctx context.Context,
webapp_session_user_id WebappSession_UserId_Field) (
rows []*WebappSession, err error) {
@ -28875,6 +29023,10 @@ type Methods interface {
bucket_metainfo_project_id BucketMetainfo_ProjectId_Field) (
count int64, err error)
Count_User_By_Status(ctx context.Context,
user_status User_Status_Field) (
count int64, err error)
CreateNoReturn_BillingBalance(ctx context.Context,
billing_balance_user_id BillingBalance_UserId_Field,
billing_balance_balance BillingBalance_Balance_Field) (
@ -29481,6 +29633,11 @@ type Methods interface {
limit int, offset int64) (
rows []*StorjscanPayment, err error)
Limited_User_Id_User_Email_User_FullName_By_Status(ctx context.Context,
user_status User_Status_Field,
limit int, offset int64) (
rows []*Id_Email_FullName_Row, err error)
Paged_BucketBandwidthRollupArchive_By_IntervalStart_GreaterOrEqual(ctx context.Context,
bucket_bandwidth_rollup_archive_interval_start_greater_or_equal BucketBandwidthRollupArchive_IntervalStart_Field,
limit int, start *Paged_BucketBandwidthRollupArchive_By_IntervalStart_GreaterOrEqual_Continuation) (

View File

@ -112,6 +112,16 @@ read one (
where user.id = ?
)
read count (
select user
where user.status = ?
)
read limitoffset (
select user.id user.email user.full_name
where user.status = ?
)
model webapp_session (
key id
index ( fields user_id )

View File

@ -88,6 +88,71 @@ func (users *users) GetByEmailWithUnverified(ctx context.Context, email string)
return verified, unverified, errors.Err()
}
func (users *users) GetByStatus(ctx context.Context, status console.UserStatus, cursor console.UserCursor) (page *console.UsersPage, err error) {
defer mon.Task()(&ctx)(&err)
if cursor.Limit == 0 {
return nil, Error.New("limit cannot be 0")
}
if cursor.Page == 0 {
return nil, Error.New("page cannot be 0")
}
page = &console.UsersPage{
Limit: cursor.Limit,
Offset: uint64((cursor.Page - 1) * cursor.Limit),
}
count, err := users.db.Count_User_By_Status(ctx, dbx.User_Status(int(status)))
if err != nil {
return nil, err
}
page.TotalCount = uint64(count)
if page.TotalCount == 0 {
return page, nil
}
if page.Offset > page.TotalCount-1 {
return nil, Error.New("page is out of range")
}
dbxUsers, err := users.db.Limited_User_Id_User_Email_User_FullName_By_Status(ctx,
dbx.User_Status(int(status)),
int(page.Limit), int64(page.Offset))
if err != nil {
if errs.Is(err, sql.ErrNoRows) {
return &console.UsersPage{
Users: []console.User{},
}, nil
}
return nil, Error.Wrap(err)
}
for _, usr := range dbxUsers {
id, err := uuid.FromBytes(usr.Id)
if err != nil {
return &console.UsersPage{
Users: []console.User{},
}, nil
}
page.Users = append(page.Users, console.User{
ID: id,
Email: usr.Email,
FullName: usr.FullName,
})
}
page.PageCount = uint(page.TotalCount / uint64(cursor.Limit))
if page.TotalCount%uint64(cursor.Limit) != 0 {
page.PageCount++
}
page.CurrentPage = cursor.Page
return page, nil
}
// GetByEmail is a method for querying user by verified email from the database.
func (users *users) GetByEmail(ctx context.Context, email string) (_ *console.User, err error) {
defer mon.Task()(&ctx)(&err)