diff --git a/satellite/admin/README.md b/satellite/admin/README.md index 81de937c8..6785c83fc 100644 --- a/satellite/admin/README.md +++ b/satellite/admin/README.md @@ -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", diff --git a/satellite/admin/server.go b/satellite/admin/server.go index b8dfc4395..6d88d1578 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -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") diff --git a/satellite/admin/ui/src/lib/api.ts b/satellite/admin/ui/src/lib/api.ts index 5b7fbeb81..4b35d1c6b 100644 --- a/satellite/admin/ui/src/lib/api.ts +++ b/satellite/admin/ui/src/lib/api.ts @@ -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> => { + return this.fetch('GET', `users-pending-deletion?limit=${limit}&page=${page}`); + } + }, { name: 'get user limits', desc: 'Get the current limits for a user', diff --git a/satellite/admin/user.go b/satellite/admin/user.go index d8787d60b..713e8b323 100644 --- a/satellite/admin/user.go +++ b/satellite/admin/user.go @@ -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() diff --git a/satellite/console/users.go b/satellite/console/users.go index b7ada3d69..2f8aef432 100644 --- a/satellite/console/users.go +++ b/satellite/console/users.go @@ -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 { diff --git a/satellite/console/users_test.go b/satellite/console/users_test.go index c4191693b..903b36a96 100644 --- a/satellite/console/users_test.go +++ b/satellite/console/users_test.go @@ -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{ diff --git a/satellite/satellitedb/dbx/satellitedb.dbx.go b/satellite/satellitedb/dbx/satellitedb.dbx.go index dca9076fd..b902b8221 100644 --- a/satellite/satellitedb/dbx/satellitedb.dbx.go +++ b/satellite/satellitedb/dbx/satellitedb.dbx.go @@ -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) ( diff --git a/satellite/satellitedb/dbx/user.dbx b/satellite/satellitedb/dbx/user.dbx index 82fe51317..5675b3827 100644 --- a/satellite/satellitedb/dbx/user.dbx +++ b/satellite/satellitedb/dbx/user.dbx @@ -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 ) diff --git a/satellite/satellitedb/users.go b/satellite/satellitedb/users.go index 5f460ae04..c8aa657d6 100644 --- a/satellite/satellitedb/users.go +++ b/satellite/satellitedb/users.go @@ -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)