diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..50da2d3d5 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -2010,6 +2010,14 @@ ui: remove: Remove their content label: Remove all questions, answers, comments, etc. text: Don’t check this if you wish to only delete the user’s account. + bulk_delete: + action: Delete selected ({{ count }}) + title: Delete {{ count }} users + content: Are you sure you want to delete {{ count }} users? This is permanent! + select: Select user + select_all: Select all users on this page + success: "{{ count }} users deleted." + partial: "{{ succeeded }} users deleted; {{ failed }} could not be deleted." suspend_user: title: Suspend this user content: A suspended user can't log in. @@ -2028,6 +2036,14 @@ ui: pending: Pending filter: placeholder: "Filter by title, question:id" + bulk_delete: + action: Delete selected ({{ count }}) + title: Delete {{ count }} questions + content: Are you sure you want to delete {{ count }} questions? + select: Select question + select_all: Select all questions on this page + success: "{{ count }} questions deleted." + partial: "{{ succeeded }} questions deleted; {{ failed }} could not be deleted." answers: page_title: Answers post: Post @@ -2038,6 +2054,14 @@ ui: change: Change filter: placeholder: "Filter by title, answer:id" + bulk_delete: + action: Delete selected ({{ count }}) + title: Delete {{ count }} answers + content: Are you sure you want to delete {{ count }} answers? + select: Select answer + select_all: Select all answers on this page + success: "{{ count }} answers deleted." + partial: "{{ succeeded }} answers deleted; {{ failed }} could not be deleted." general: page_title: General name: @@ -2488,4 +2512,3 @@ ui: copy: Copy to clipboard copied: Copied external_content_warning: External images/media are not displayed. - diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index f16ed9fad..bd79e3f2a 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -1969,6 +1969,14 @@ ui: remove: 移除内容 label: 删除所有问题、 答案、 评论等 text: 如果你只想删除用户账户,请不要选中此项。 + bulk_delete: + action: 删除所选项 ({{ count }}) + title: 删除 {{ count }} 位用户 + content: 确定要删除 {{ count }} 位用户?此操作无法撤销! + select: 选择用户 + select_all: 选择本页所有用户 + success: "已删除 {{ count }} 位用户。" + partial: "已删除 {{ succeeded }} 位用户;{{ failed }} 位用户无法删除。" suspend_user: title: 挂起此用户 content: 被封禁的用户将无法登录。 @@ -1987,6 +1995,14 @@ ui: pending: 等待处理 filter: placeholder: "按标题过滤,问题:id" + bulk_delete: + action: 删除所选项 ({{ count }}) + title: 删除 {{ count }} 个问题 + content: 确定要删除 {{ count }} 个问题? + select: 选择问题 + select_all: 选择本页所有问题 + success: "已删除 {{ count }} 个问题。" + partial: "已删除 {{ succeeded }} 个问题;{{ failed }} 个问题无法删除。" answers: page_title: 回答 post: 标题 @@ -1997,6 +2013,14 @@ ui: change: 更改 filter: placeholder: "按标题筛选,答案:id" + bulk_delete: + action: 删除所选项 ({{ count }}) + title: 删除 {{ count }} 个回答 + content: 确定要删除 {{ count }} 个回答? + select: 选择回答 + select_all: 选择本页所有回答 + success: "已删除 {{ count }} 个回答。" + partial: "已删除 {{ succeeded }} 个回答;{{ failed }} 个回答无法删除。" general: page_title: 一般 name: @@ -2447,4 +2471,3 @@ ui: copied: 已复制 external_content_warning: 外部图像/媒体未显示。 - diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go index 6e16c6a85..ea38e47a2 100644 --- a/internal/controller/answer_controller.go +++ b/internal/controller/answer_controller.go @@ -457,3 +457,27 @@ func (ac *AnswerController) AdminUpdateAnswerStatus(ctx *gin.Context) { err := ac.answerService.AdminSetAnswerStatus(ctx, req) handler.HandleResponse(ctx, err, nil) } + +// AdminDeleteAnswers deletes multiple answers. +// @Summary delete multiple answers +// @Description delete multiple answers +// @Tags admin +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.DeleteAnswersReq true "DeleteAnswersReq" +// @Success 200 {object} handler.RespBody{data=schema.BulkDeleteResp} +// @Router /answer/admin/api/answers [delete] +func (ac *AnswerController) AdminDeleteAnswers(ctx *gin.Context) { + req := &schema.DeleteAnswersReq{} + if handler.BindAndCheck(ctx, req) { + return + } + for index, answerID := range req.AnswerIDs { + req.AnswerIDs[index] = uid.DeShortID(answerID) + } + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp := ac.answerService.AdminDeleteAnswers(ctx, req) + handler.HandleResponse(ctx, nil, resp) +} diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 05ad319ab..5f2264569 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -976,6 +976,30 @@ func (qc *QuestionController) AdminUpdateQuestionStatus(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } +// AdminDeleteQuestions deletes multiple questions. +// @Summary delete multiple questions +// @Description delete multiple questions +// @Tags admin +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.DeleteQuestionsReq true "DeleteQuestionsReq" +// @Success 200 {object} handler.RespBody{data=schema.BulkDeleteResp} +// @Router /answer/admin/api/questions [delete] +func (qc *QuestionController) AdminDeleteQuestions(ctx *gin.Context) { + req := &schema.DeleteQuestionsReq{} + if handler.BindAndCheck(ctx, req) { + return + } + for index, questionID := range req.QuestionIDs { + req.QuestionIDs[index] = uid.DeShortID(questionID) + } + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp := qc.questionService.AdminDeleteQuestions(ctx, req) + handler.HandleResponse(ctx, nil, resp) +} + // GetQuestionLink get question link // @Summary get question link // @Description get question link diff --git a/internal/controller_admin/user_backyard_controller.go b/internal/controller_admin/user_backyard_controller.go index 1356c55a1..4a172eb34 100644 --- a/internal/controller_admin/user_backyard_controller.go +++ b/internal/controller_admin/user_backyard_controller.go @@ -67,6 +67,31 @@ func (uc *UserAdminController) UpdateUserStatus(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } +// DeleteUsers deletes multiple users. +// @Summary delete multiple users +// @Description delete multiple users +// @Security ApiKeyAuth +// @Tags admin +// @Accept json +// @Produce json +// @Param data body schema.DeleteUsersReq true "DeleteUsersReq" +// @Success 200 {object} handler.RespBody{data=schema.BulkDeleteResp} +// @Router /answer/admin/api/users [delete] +func (uc *UserAdminController) DeleteUsers(ctx *gin.Context) { + if u, ok := plugin.GetUserCenter(); ok && u.Description().UserStatusAgentEnabled { + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) + return + } + req := &schema.DeleteUsersReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + resp := uc.userService.DeleteUsers(ctx, req) + handler.HandleResponse(ctx, nil, resp) +} + // UpdateUserRole update user role // @Summary update user role // @Description update user role diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 84b8b4e1c..0878c37eb 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -336,12 +336,15 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { r.GET("/question/page", a.questionController.AdminQuestionPage) r.PUT("/question/status", a.questionController.AdminUpdateQuestionStatus) + r.DELETE("/questions", a.questionController.AdminDeleteQuestions) r.GET("/answer/page", a.questionController.AdminAnswerPage) r.PUT("/answer/status", a.answerController.AdminUpdateAnswerStatus) + r.DELETE("/answers", a.answerController.AdminDeleteAnswers) // user r.GET("/users/page", a.adminUserController.GetUserPage) r.PUT("/user/status", a.adminUserController.UpdateUserStatus) + r.DELETE("/users", a.adminUserController.DeleteUsers) r.PUT("/user/role", a.adminUserController.UpdateUserRole) r.GET("/user/activation", a.adminUserController.GetUserActivation) r.POST("/user/activation", a.adminUserController.SendUserActivation) diff --git a/internal/schema/answer_schema.go b/internal/schema/answer_schema.go index bf80c56ec..f25c46980 100644 --- a/internal/schema/answer_schema.go +++ b/internal/schema/answer_schema.go @@ -174,3 +174,9 @@ type AdminUpdateAnswerStatusReq struct { Status string `validate:"required,oneof=available deleted" json:"status"` UserID string `json:"-"` } + +// DeleteAnswersReq deletes multiple answers from the admin console. +type DeleteAnswersReq struct { + AnswerIDs []string `validate:"required,min=1,max=500,dive,required" json:"answer_ids"` + UserID string `json:"-"` +} diff --git a/internal/schema/backyard_user_schema.go b/internal/schema/backyard_user_schema.go index d36ff8083..8223d5284 100644 --- a/internal/schema/backyard_user_schema.go +++ b/internal/schema/backyard_user_schema.go @@ -47,6 +47,19 @@ func (r *UpdateUserStatusReq) IsSuspended() bool { return r.Status == constant.U func (r *UpdateUserStatusReq) IsDeleted() bool { return r.Status == constant.UserDeleted } func (r *UpdateUserStatusReq) IsInactive() bool { return r.Status == constant.UserInactive } +// DeleteUsersReq deletes multiple users from the admin console. +type DeleteUsersReq struct { + UserIDs []string `validate:"required,min=1,max=500,dive,required" json:"user_ids"` + RemoveAllContent bool `json:"remove_all_content"` + LoginUserID string `json:"-"` +} + +// BulkDeleteResp reports the IDs that were processed successfully and the IDs that failed. +type BulkDeleteResp struct { + SucceededIDs []string `json:"succeeded_ids"` + FailedIDs []string `json:"failed_ids"` +} + // GetSuspendedUntil calculates the suspended until time based on duration func (r *UpdateUserStatusReq) GetSuspendedUntil() time.Time { if !r.IsSuspended() || r.SuspendDuration == "" || r.SuspendDuration == "forever" { diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 4c2313852..6014e3360 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -489,6 +489,12 @@ type AdminUpdateQuestionStatusReq struct { UserID string `json:"-"` } +// DeleteQuestionsReq deletes multiple questions from the admin console. +type DeleteQuestionsReq struct { + QuestionIDs []string `validate:"required,min=1,max=500,dive,required" json:"question_ids"` + UserID string `json:"-"` +} + type PersonalQuestionPageReq struct { Page int `validate:"omitempty,min=1" form:"page"` PageSize int `validate:"omitempty,min=1" form:"page_size"` diff --git a/internal/service/bulk_delete/bulk_delete.go b/internal/service/bulk_delete/bulk_delete.go new file mode 100644 index 000000000..288c2e547 --- /dev/null +++ b/internal/service/bulk_delete/bulk_delete.go @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package bulk_delete + +import "github.com/apache/answer/internal/schema" + +// Execute applies a delete operation once per unique ID and records partial failures. +func Execute(ids []string, deleteFunc func(string) error) *schema.BulkDeleteResp { + resp := &schema.BulkDeleteResp{ + SucceededIDs: make([]string, 0), + FailedIDs: make([]string, 0), + } + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + + if err := deleteFunc(id); err != nil { + resp.FailedIDs = append(resp.FailedIDs, id) + continue + } + resp.SucceededIDs = append(resp.SucceededIDs, id) + } + return resp +} diff --git a/internal/service/bulk_delete/bulk_delete_test.go b/internal/service/bulk_delete/bulk_delete_test.go new file mode 100644 index 000000000..d3ca33f28 --- /dev/null +++ b/internal/service/bulk_delete/bulk_delete_test.go @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package bulk_delete + +import ( + "errors" + "reflect" + "testing" +) + +func TestExecute(t *testing.T) { + called := make([]string, 0) + result := Execute([]string{"1", "2", "1", "3"}, func(id string) error { + called = append(called, id) + if id == "2" { + return errors.New("delete failed") + } + return nil + }) + + if !reflect.DeepEqual(called, []string{"1", "2", "3"}) { + t.Fatalf("unexpected calls: %v", called) + } + if !reflect.DeepEqual(result.SucceededIDs, []string{"1", "3"}) { + t.Fatalf("unexpected successful IDs: %v", result.SucceededIDs) + } + if !reflect.DeepEqual(result.FailedIDs, []string{"2"}) { + t.Fatalf("unexpected failed IDs: %v", result.FailedIDs) + } +} + +func TestExecuteReturnsEmptyArrays(t *testing.T) { + result := Execute(nil, func(string) error { return nil }) + if result.SucceededIDs == nil || result.FailedIDs == nil { + t.Fatal("expected initialized result arrays") + } +} diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index bda7b582b..d976aab4a 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -34,6 +34,7 @@ import ( "github.com/apache/answer/internal/service/activity_common" "github.com/apache/answer/internal/service/activityqueue" answercommon "github.com/apache/answer/internal/service/answer_common" + bulkdelete "github.com/apache/answer/internal/service/bulk_delete" collectioncommon "github.com/apache/answer/internal/service/collection_common" "github.com/apache/answer/internal/service/export" "github.com/apache/answer/internal/service/noticequeue" @@ -636,6 +637,9 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A if !exist { return errors.BadRequest(reason.AnswerNotFound) } + if answerInfo.Status == setStatus { + return nil + } if setStatus == entity.AnswerStatusDeleted { if err := as.RemoveAnswer(ctx, &schema.RemoveAnswerReq{ @@ -657,7 +661,7 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A } // recover - if setStatus == entity.QuestionStatusAvailable && answerInfo.Status == entity.QuestionStatusDeleted { + if setStatus == entity.AnswerStatusAvailable && answerInfo.Status == entity.AnswerStatusDeleted { if err := as.RecoverAnswer(ctx, &schema.RecoverAnswerReq{ AnswerID: req.AnswerID, UserID: req.UserID, @@ -676,6 +680,17 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A return nil } +// AdminDeleteAnswers deletes answers one at a time to preserve the existing admin delete side effects. +func (as *AnswerService) AdminDeleteAnswers(ctx context.Context, req *schema.DeleteAnswersReq) *schema.BulkDeleteResp { + return bulkdelete.Execute(req.AnswerIDs, func(answerID string) error { + return as.AdminSetAnswerStatus(ctx, &schema.AdminUpdateAnswerStatusReq{ + AnswerID: answerID, + Status: "deleted", + UserID: req.UserID, + }) + }) +} + func (as *AnswerService) SearchList(ctx context.Context, req *schema.AnswerListReq) ([]*schema.AnswerInfo, int64, error) { list := make([]*schema.AnswerInfo, 0) questionInfo, exist, err := as.questionRepo.GetQuestion(ctx, req.QuestionID) diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index 73f66a4c1..c6c7b2dac 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -41,6 +41,7 @@ import ( "github.com/apache/answer/internal/service/activity_common" "github.com/apache/answer/internal/service/activityqueue" answercommon "github.com/apache/answer/internal/service/answer_common" + bulkdelete "github.com/apache/answer/internal/service/bulk_delete" collectioncommon "github.com/apache/answer/internal/service/collection_common" "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/export" @@ -1582,26 +1583,30 @@ func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, req *sche if !exist { return errors.BadRequest(reason.QuestionNotFound) } - err = qs.questionRepo.UpdateQuestionStatus(ctx, questionInfo.ID, setStatus) + if questionInfo.Status == setStatus { + return nil + } + switch { + case setStatus == entity.QuestionStatusDeleted: + err = qs.RemoveQuestion(ctx, &schema.RemoveQuestionReq{ + ID: questionInfo.ID, + UserID: req.UserID, + IsAdmin: true, + }) + case setStatus == entity.QuestionStatusAvailable && questionInfo.Status == entity.QuestionStatusDeleted: + err = qs.RecoverQuestion(ctx, &schema.QuestionRecoverReq{ + QuestionID: questionInfo.ID, + UserID: req.UserID, + }) + default: + err = qs.questionRepo.UpdateQuestionStatus(ctx, questionInfo.ID, setStatus) + } if err != nil { return err } msg := &schema.NotificationMsg{} if setStatus == entity.QuestionStatusDeleted { - // #2372 In order to simplify the process and complexity, as well as to consider if it is in-house, - // facing the problem of recovery. - // err = qs.answerActivityService.DeleteQuestion(ctx, questionInfo.ID, questionInfo.CreatedAt, questionInfo.VoteCount) - // if err != nil { - // log.Errorf("admin delete question then rank rollback error %s", err.Error()) - // } - qs.activityQueueService.Send(ctx, &schema.ActivityMsg{ - UserID: questionInfo.UserID, - TriggerUserID: converter.StringToInt64(req.UserID), - ObjectID: questionInfo.ID, - OriginalObjectID: questionInfo.ID, - ActivityTypeKey: constant.ActQuestionDeleted, - }) msg.NotificationAction = constant.NotificationYourQuestionWasDeleted } if setStatus == entity.QuestionStatusAvailable && questionInfo.Status == entity.QuestionStatusClosed { @@ -1623,17 +1628,6 @@ func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, req *sche }) msg.NotificationAction = constant.NotificationYourQuestionIsClosed } - // recover - if setStatus == entity.QuestionStatusAvailable && questionInfo.Status == entity.QuestionStatusDeleted { - qs.activityQueueService.Send(ctx, &schema.ActivityMsg{ - UserID: req.UserID, - TriggerUserID: converter.StringToInt64(req.UserID), - ObjectID: questionInfo.ID, - OriginalObjectID: questionInfo.ID, - ActivityTypeKey: constant.ActQuestionUndeleted, - }) - } - if len(msg.NotificationAction) > 0 { msg.ObjectID = questionInfo.ID msg.Type = schema.NotificationTypeInbox @@ -1642,15 +1636,23 @@ func (qs *QuestionService) AdminSetQuestionStatus(ctx context.Context, req *sche msg.ObjectType = constant.QuestionObjectType qs.notificationQueueService.Send(ctx, msg) } - switch setStatus { - case entity.QuestionStatusDeleted: - qs.vectorSyncService.Send(ctx, &vector_sync.Task{Action: vector_sync.ActionDelete, ObjectType: vector_sync.ObjectTypeQuestion, ObjectID: questionInfo.ID}) - case entity.QuestionStatusAvailable: + if setStatus == entity.QuestionStatusAvailable && questionInfo.Status != entity.QuestionStatusDeleted { qs.vectorSyncService.Send(ctx, &vector_sync.Task{Action: vector_sync.ActionUpsert, ObjectType: vector_sync.ObjectTypeQuestion, ObjectID: questionInfo.ID}) } return nil } +// AdminDeleteQuestions deletes questions one at a time to preserve the existing admin delete side effects. +func (qs *QuestionService) AdminDeleteQuestions(ctx context.Context, req *schema.DeleteQuestionsReq) *schema.BulkDeleteResp { + return bulkdelete.Execute(req.QuestionIDs, func(questionID string) error { + return qs.AdminSetQuestionStatus(ctx, &schema.AdminUpdateQuestionStatusReq{ + QuestionID: questionID, + Status: "deleted", + UserID: req.UserID, + }) + }) +} + func (qs *QuestionService) AdminQuestionPage( ctx context.Context, req *schema.AdminQuestionPageReq) ( resp *pager.PageModel, err error) { diff --git a/internal/service/user_admin/user_backyard.go b/internal/service/user_admin/user_backyard.go index 29e338046..def3f09de 100644 --- a/internal/service/user_admin/user_backyard.go +++ b/internal/service/user_admin/user_backyard.go @@ -33,6 +33,7 @@ import ( "github.com/apache/answer/internal/base/validator" answercommon "github.com/apache/answer/internal/service/answer_common" "github.com/apache/answer/internal/service/badge" + bulkdelete "github.com/apache/answer/internal/service/bulk_delete" "github.com/apache/answer/internal/service/comment_common" "github.com/apache/answer/internal/service/export" notificationcommon "github.com/apache/answer/internal/service/notification_common" @@ -141,30 +142,32 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up if !exist { return errors.BadRequest(reason.UserNotFound) } - // if user status is deleted - if userInfo.Status == entity.UserStatusDeleted { + alreadyDeleted := userInfo.Status == entity.UserStatusDeleted + if alreadyDeleted && !req.IsDeleted() { return nil } - if req.IsInactive() { - userInfo.MailStatus = entity.EmailStatusToBeVerified - } - if req.IsDeleted() { - userInfo.Status = entity.UserStatusDeleted - userInfo.EMail = fmt.Sprintf("%s.%d", userInfo.EMail, time.Now().Unix()) - } - if req.IsSuspended() { - userInfo.Status = entity.UserStatusSuspended - } - if req.IsNormal() { - userInfo.Status = entity.UserStatusAvailable - userInfo.MailStatus = entity.EmailStatusAvailable - } + if !alreadyDeleted { + if req.IsInactive() { + userInfo.MailStatus = entity.EmailStatusToBeVerified + } + if req.IsDeleted() { + userInfo.Status = entity.UserStatusDeleted + userInfo.EMail = fmt.Sprintf("%s.%d", userInfo.EMail, time.Now().Unix()) + } + if req.IsSuspended() { + userInfo.Status = entity.UserStatusSuspended + } + if req.IsNormal() { + userInfo.Status = entity.UserStatusAvailable + userInfo.MailStatus = entity.EmailStatusAvailable + } - suspendedUntil := req.GetSuspendedUntil() - err = us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail, suspendedUntil) - if err != nil { - return err + suspendedUntil := req.GetSuspendedUntil() + err = us.userRepo.UpdateUserStatus(ctx, userInfo.ID, userInfo.Status, userInfo.MailStatus, userInfo.EMail, suspendedUntil) + if err != nil { + return err + } } if req.IsInactive() || req.IsSuspended() || req.IsDeleted() { if err := us.revokeUserAPIKeys(ctx, userInfo.ID); err != nil { @@ -172,13 +175,18 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up } } - // remove all content that user created, such as question, answer, comment, etc. + var cleanupErr error if req.RemoveAllContent { - us.removeAllUserCreatedContent(ctx, userInfo.ID) + cleanupErr = us.removeAllUserCreatedContent(ctx, userInfo.ID) } if req.IsDeleted() { - us.removeAllUserConfiguration(ctx, userInfo.ID) + if err := us.removeAllUserConfiguration(ctx, userInfo.ID); cleanupErr == nil { + cleanupErr = err + } + } + if cleanupErr != nil { + return cleanupErr } // if user reputation is zero means this user is inactive, so try to activate this user. @@ -188,41 +196,75 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up return nil } +// DeleteUsers deletes users one at a time to preserve the existing user deletion workflow. +func (us *UserAdminService) DeleteUsers(ctx context.Context, req *schema.DeleteUsersReq) *schema.BulkDeleteResp { + return bulkdelete.Execute(req.UserIDs, func(userID string) error { + return us.UpdateUserStatus(ctx, &schema.UpdateUserStatusReq{ + UserID: userID, + Status: constant.UserDeleted, + RemoveAllContent: req.RemoveAllContent, + LoginUserID: req.LoginUserID, + }) + }) +} + // removeAllUserConfiguration remove all user configuration -func (us *UserAdminService) removeAllUserConfiguration(ctx context.Context, userID string) { +func (us *UserAdminService) removeAllUserConfiguration(ctx context.Context, userID string) (firstErr error) { err := us.userExternalLoginRepo.DeleteUserExternalLoginByUserID(ctx, userID) if err != nil { log.Errorf("remove all user external login error: %v", err) + firstErr = err } err = us.notificationRepo.DeleteNotification(ctx, userID) if err != nil { log.Errorf("remove all user notification error: %v", err) + if firstErr == nil { + firstErr = err + } } err = us.notificationRepo.DeleteUserNotificationConfig(ctx, userID) if err != nil { log.Errorf("remove all user notification config error: %v", err) + if firstErr == nil { + firstErr = err + } } err = us.pluginUserConfigRepo.DeleteUserPluginConfig(ctx, userID) if err != nil { log.Errorf("remove all user plugin config error: %v", err) + if firstErr == nil { + firstErr = err + } } err = us.badgeAwardRepo.DeleteUserBadgeAward(ctx, userID) if err != nil { log.Errorf("remove all user badge award error: %v", err) + if firstErr == nil { + firstErr = err + } } + return firstErr } // removeAllUserCreatedContent remove all user created content -func (us *UserAdminService) removeAllUserCreatedContent(ctx context.Context, userID string) { +func (us *UserAdminService) removeAllUserCreatedContent(ctx context.Context, userID string) (firstErr error) { if err := us.questionCommonRepo.RemoveAllUserQuestion(ctx, userID); err != nil { log.Errorf("remove all user question error: %v", err) + firstErr = err } if err := us.answerCommonRepo.RemoveAllUserAnswer(ctx, userID); err != nil { log.Errorf("remove all user answer error: %v", err) + if firstErr == nil { + firstErr = err + } } if err := us.commentCommonRepo.RemoveAllUserComment(ctx, userID); err != nil { log.Errorf("remove all user comment error: %v", err) + if firstErr == nil { + firstErr = err + } } + return firstErr } // UpdateUserRole update user role diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 8ab714230..50b39eba7 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -323,6 +323,11 @@ export interface AdminContentsReq extends Paging { query?: string; } +export interface BulkDeleteResult { + succeeded_ids: string[]; + failed_ids: string[]; +} + /** * @description interface for Answer */ diff --git a/ui/src/pages/Admin/Answers/index.tsx b/ui/src/pages/Admin/Answers/index.tsx index 9909739f5..5d70de35c 100644 --- a/ui/src/pages/Admin/Answers/index.tsx +++ b/ui/src/pages/Admin/Answers/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { FC } from 'react'; +import { FC, useEffect, useState } from 'react'; import { Form, Table, Stack, Button } from 'react-bootstrap'; import { useSearchParams, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -36,7 +36,7 @@ import { } from '@/components'; import { ADMIN_LIST_STATUS, ADMIN_QA_NAV_MENUS } from '@/common/constants'; import * as Type from '@/common/interface'; -import { deletePermanently, useAnswerSearch } from '@/services'; +import { deleteAnswers, deletePermanently, useAnswerSearch } from '@/services'; import { escapeRemove } from '@/utils'; import { pathFactory } from '@/router/pathFactory'; import { toastStore } from '@/stores'; @@ -56,6 +56,7 @@ const Answers: FC = () => { const curPage = Number(urlSearchParams.get('page')) || 1; const curQuery = urlSearchParams.get('query') || ''; const questionId = urlSearchParams.get('questionId') || ''; + const [selectedAnswerIDs, setSelectedAnswerIDs] = useState([]); const { t } = useTranslation('translation', { keyPrefix: 'admin.answers' }); const { @@ -70,6 +71,15 @@ const Answers: FC = () => { question_id: questionId, }); const count = listData?.count || 0; + const canBulkDelete = curFilter === 'normal'; + const pageAnswerIDs = listData?.list?.map((item) => item.id) || []; + const allAnswersSelected = + pageAnswerIDs.length > 0 && + pageAnswerIDs.every((answerID) => selectedAnswerIDs.includes(answerID)); + + useEffect(() => { + setSelectedAnswerIDs([]); + }, [curFilter, curPage, curQuery, questionId]); const handleDeletePermanently = () => { Modal.confirm({ @@ -95,6 +105,48 @@ const Answers: FC = () => { urlSearchParams.delete('page'); setUrlSearchParams(urlSearchParams); }; + + const toggleAnswer = (answerID: string) => { + setSelectedAnswerIDs((selectedIDs) => + selectedIDs.includes(answerID) + ? selectedIDs.filter((id) => id !== answerID) + : [...selectedIDs, answerID], + ); + }; + + const toggleAllAnswers = () => { + setSelectedAnswerIDs(allAnswersSelected ? [] : pageAnswerIDs); + }; + + const handleBulkDelete = () => { + Modal.confirm({ + title: t('bulk_delete.title', { count: selectedAnswerIDs.length }), + content: t('bulk_delete.content', { count: selectedAnswerIDs.length }), + cancelBtnVariant: 'link', + confirmText: t('delete', { keyPrefix: 'btns' }), + confirmBtnVariant: 'danger', + onConfirm: () => { + deleteAnswers(selectedAnswerIDs).then((result) => { + const failedCount = result.failed_ids.length; + toastStore.getState().show({ + msg: + failedCount > 0 + ? t('bulk_delete.partial', { + succeeded: result.succeeded_ids.length, + failed: failedCount, + }) + : t('bulk_delete.success', { + count: result.succeeded_ids.length, + }), + variant: failedCount > 0 ? 'warning' : 'success', + }); + setSelectedAnswerIDs([]); + refreshList(); + }); + }, + }); + }; + return ( <>

@@ -117,6 +169,15 @@ const Answers: FC = () => { {t('deleted_permanently', { keyPrefix: 'btns' })} ) : null} + {canBulkDelete ? ( + + ) : null} { + {canBulkDelete ? ( + + ) : null} @@ -145,6 +216,16 @@ const Answers: FC = () => { {listData?.list?.map((li) => { return ( + {canBulkDelete ? ( + + ) : null}
+ + {t('post')} {t('votes')} {t('created')}
+ toggleAnswer(li.id)} + aria-label={t('bulk_delete.select')} + /> + { const curFilter = urlSearchParams.get('status') || questionFilterItems[0]; const curPage = Number(urlSearchParams.get('page')) || 1; const curQuery = urlSearchParams.get('query') || ''; + const [selectedQuestionIDs, setSelectedQuestionIDs] = useState([]); const { t } = useTranslation('translation', { keyPrefix: 'admin.questions' }); const { @@ -68,6 +73,17 @@ const Questions: FC = () => { query: curQuery, }); const count = listData?.count || 0; + const canBulkDelete = curFilter === 'normal' || curFilter === 'closed'; + const pageQuestionIDs = listData?.list?.map((item) => item.id) || []; + const allQuestionsSelected = + pageQuestionIDs.length > 0 && + pageQuestionIDs.every((questionID) => + selectedQuestionIDs.includes(questionID), + ); + + useEffect(() => { + setSelectedQuestionIDs([]); + }, [curFilter, curPage, curQuery]); const handleDeletePermanently = () => { Modal.confirm({ @@ -93,6 +109,48 @@ const Questions: FC = () => { urlSearchParams.delete('page'); setUrlSearchParams(urlSearchParams); }; + + const toggleQuestion = (questionID: string) => { + setSelectedQuestionIDs((selectedIDs) => + selectedIDs.includes(questionID) + ? selectedIDs.filter((id) => id !== questionID) + : [...selectedIDs, questionID], + ); + }; + + const toggleAllQuestions = () => { + setSelectedQuestionIDs(allQuestionsSelected ? [] : pageQuestionIDs); + }; + + const handleBulkDelete = () => { + Modal.confirm({ + title: t('bulk_delete.title', { count: selectedQuestionIDs.length }), + content: t('bulk_delete.content', { count: selectedQuestionIDs.length }), + cancelBtnVariant: 'link', + confirmText: t('delete', { keyPrefix: 'btns' }), + confirmBtnVariant: 'danger', + onConfirm: () => { + deleteQuestions(selectedQuestionIDs).then((result) => { + const failedCount = result.failed_ids.length; + toastStore.getState().show({ + msg: + failedCount > 0 + ? t('bulk_delete.partial', { + succeeded: result.succeeded_ids.length, + failed: failedCount, + }) + : t('bulk_delete.success', { + count: result.succeeded_ids.length, + }), + variant: failedCount > 0 ? 'warning' : 'success', + }); + setSelectedQuestionIDs([]); + refreshList(); + }); + }, + }); + }; + return ( <>

{t('page_title')}

@@ -113,6 +171,17 @@ const Questions: FC = () => { {t('deleted_permanently', { keyPrefix: 'btns' })} ) : null} + {canBulkDelete ? ( + + ) : null} { + {canBulkDelete ? ( + + ) : null} @@ -142,6 +221,16 @@ const Questions: FC = () => { {listData?.list?.map((li) => { return ( + {canBulkDelete ? ( + + ) : null}
+ + {t('post')} {t('votes')} {t('answers')}
+ toggleQuestion(li.id)} + aria-label={t('bulk_delete.select')} + /> + { +const DeleteUserModal = ({ + show, + onClose, + onDelete, + count = 1, + isBulk = false, +}) => { const { t } = useTranslation('translation', { keyPrefix: 'admin.users' }); const [checkVal, setCheckVal] = useState(false); @@ -33,10 +39,16 @@ const DeleteUserModal = ({ show, onClose, onDelete }) => { return ( - {t('delete_user.title')} + + {t(isBulk ? 'bulk_delete.title' : 'delete_user.title', { count })} + -

{t('delete_user.content')}

+

+ {t(isBulk ? 'bulk_delete.content' : 'delete_user.content', { + count, + })} +

{t('delete_user.remove')} {t('optional', { keyPrefix: 'form' })}
@@ -65,7 +77,12 @@ const DeleteUserModal = ({ show, onClose, onDelete }) => { - diff --git a/ui/src/pages/Admin/Users/index.tsx b/ui/src/pages/Admin/Users/index.tsx index 200aacf38..8446d827c 100644 --- a/ui/src/pages/Admin/Users/index.tsx +++ b/ui/src/pages/Admin/Users/index.tsx @@ -43,6 +43,7 @@ import { getAdminUcAgent, AdminUcAgent, changeUserStatus, + deleteUsers, deletePermanently, } from '@/services'; import { formatCount } from '@/utils'; @@ -78,6 +79,8 @@ const Users: FC = () => { show: false, userId: '', }); + const [showBulkDeleteUsers, setShowBulkDeleteUsers] = useState(false); + const [selectedUserIDs, setSelectedUserIDs] = useState([]); const [urlSearchParams, setUrlSearchParams] = useSearchParams(); const curFilter = urlSearchParams.get('filter') || UserFilterKeys[0]; const curPage = Number(urlSearchParams.get('page') || '1'); @@ -206,6 +209,54 @@ const Users: FC = () => { !ucAgent?.enabled || (ucAgent?.enabled && adminUcAgent?.allow_update_user_status); const showAction = showActionPassword || showActionRole || showActionStatus; + const canBulkDelete = showActionStatus && curFilter !== 'deleted'; + const pageUserIDs = + data?.list + .filter((user) => user.user_id !== currentUser?.id) + .map((user) => user.user_id) || []; + const allUsersSelected = + pageUserIDs.length > 0 && + pageUserIDs.every((userID) => selectedUserIDs.includes(userID)); + + useEffect(() => { + setSelectedUserIDs([]); + }, [curFilter, curPage, curQuery]); + + const toggleUser = (userID: string) => { + setSelectedUserIDs((selectedIDs) => + selectedIDs.includes(userID) + ? selectedIDs.filter((id) => id !== userID) + : [...selectedIDs, userID], + ); + }; + + const toggleAllUsers = () => { + setSelectedUserIDs(allUsersSelected ? [] : pageUserIDs); + }; + + const handleBulkDelete = (removeAllContent: boolean) => { + deleteUsers({ + user_ids: selectedUserIDs, + remove_all_content: removeAllContent, + }).then((result) => { + const failedCount = result.failed_ids.length; + toastStore.getState().show({ + msg: + failedCount > 0 + ? t('bulk_delete.partial', { + succeeded: result.succeeded_ids.length, + failed: failedCount, + }) + : t('bulk_delete.success', { + count: result.succeeded_ids.length, + }), + variant: failedCount > 0 ? 'warning' : 'success', + }); + setSelectedUserIDs([]); + setShowBulkDeleteUsers(false); + refreshUsers(); + }); + }; return ( <> @@ -227,6 +278,15 @@ const Users: FC = () => { {t('deleted_permanently', { keyPrefix: 'btns' })} ) : null} + {canBulkDelete ? ( + + ) : null} {showAddUser ? (