diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4928a122b..cc245aea5 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -40,6 +40,9 @@ import ( "github.com/apache/incubator-answer/internal/repo/activity_common" "github.com/apache/incubator-answer/internal/repo/answer" "github.com/apache/incubator-answer/internal/repo/auth" + "github.com/apache/incubator-answer/internal/repo/badge" + "github.com/apache/incubator-answer/internal/repo/badge_award" + "github.com/apache/incubator-answer/internal/repo/badge_group" "github.com/apache/incubator-answer/internal/repo/captcha" "github.com/apache/incubator-answer/internal/repo/collection" "github.com/apache/incubator-answer/internal/repo/comment" @@ -71,6 +74,7 @@ import ( "github.com/apache/incubator-answer/internal/service/activity_queue" "github.com/apache/incubator-answer/internal/service/answer_common" auth2 "github.com/apache/incubator-answer/internal/service/auth" + badge2 "github.com/apache/incubator-answer/internal/service/badge" collection2 "github.com/apache/incubator-answer/internal/service/collection" "github.com/apache/incubator-answer/internal/service/collection_common" comment2 "github.com/apache/incubator-answer/internal/service/comment" @@ -78,6 +82,7 @@ import ( config2 "github.com/apache/incubator-answer/internal/service/config" "github.com/apache/incubator-answer/internal/service/content" "github.com/apache/incubator-answer/internal/service/dashboard" + "github.com/apache/incubator-answer/internal/service/event_queue" export2 "github.com/apache/incubator-answer/internal/service/export" "github.com/apache/incubator-answer/internal/service/follow" meta2 "github.com/apache/incubator-answer/internal/service/meta" @@ -172,7 +177,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, metaRepo := meta.NewMetaRepo(dataData) metaCommonService := metacommon.NewMetaCommonService(metaRepo) questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, activityQueueService, revisionRepo, dataData) - userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon) + eventQueueService := event_queue.NewEventQueueService() + userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventQueueService) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService) @@ -181,7 +187,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, objService := object_info.NewObjService(answerRepo, questionRepo, commentCommonRepo, tagCommonRepo, tagCommonService) notificationQueueService := notice_queue.NewNotificationQueueService() externalNotificationQueueService := notice_queue.NewNewQuestionNotificationQueueService() - commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, notificationQueueService, externalNotificationQueueService, activityQueueService) + commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, notificationQueueService, externalNotificationQueueService, activityQueueService, eventQueueService) rolePowerRelRepo := role.NewRolePowerRelRepo(dataData) rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService) rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService) @@ -194,13 +200,13 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalNotificationQueueService, userExternalLoginRepo, siteInfoCommonService) reviewRepo := review.NewReviewRepo(dataData) reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalNotificationQueueService, tagCommonService, questionCommon, notificationQueueService, siteInfoCommonService) - questionService := content.NewQuestionService(questionRepo, answerRepo, tagCommonService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService, reviewService, configService) - answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService, reviewService) + questionService := content.NewQuestionService(questionRepo, answerRepo, tagCommonService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService, reviewService, configService, eventQueueService) + answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService, reviewService, eventQueueService) reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService) - reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService) + reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventQueueService) reportController := controller.NewReportController(reportService, rankService, captchaService) contentVoteRepo := activity.NewVoteRepo(dataData, activityRepo, userRankRepo, notificationQueueService) - voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService) + voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService, eventQueueService) voteController := controller.NewVoteController(voteService, rankService, captchaService) tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, activityQueueService) tagController := controller.NewTagController(tagService, tagCommonService, rankService) @@ -232,7 +238,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService) notificationRepo := notification2.NewNotificationRepo(dataData) notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, notificationQueueService, userExternalLoginRepo, siteInfoCommonService) - notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService) + badgeRepo := badge.NewBadgeRepo(dataData, uniqueIDRepo) + notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService, badgeRepo) notificationController := controller.NewNotificationController(notificationService, rankService) dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configService, siteInfoCommonService, serviceConf, reviewService, revisionRepo, dataData) dashboardController := controller.NewDashboardController(dashboardService) @@ -251,16 +258,24 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, permissionController := controller.NewPermissionController(rankService) userPluginController := controller.NewUserPluginController(pluginCommonService) reviewController := controller.NewReviewController(reviewService, rankService, captchaService) - metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo) + metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, eventQueueService) metaController := controller.NewMetaController(metaService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController) + badgeGroupRepo := badge_group.NewBadgeGroupRepo(dataData, uniqueIDRepo) + badgeAwardRepo := badge_award.NewBadgeAwardRepo(dataData, uniqueIDRepo) + eventRuleRepo := badge.NewEventRuleRepo(dataData) + badgeAwardService := badge2.NewBadgeAwardService(badgeAwardRepo, badgeRepo, userCommon, objService, notificationQueueService) + badgeEventService := badge2.NewBadgeEventService(dataData, eventQueueService, badgeRepo, eventRuleRepo, badgeAwardService) + badgeService := badge2.NewBadgeService(badgeRepo, badgeGroupRepo, badgeAwardRepo, badgeEventService) + badgeController := controller.NewBadgeController(badgeService, badgeAwardService) + controller_adminBadgeController := controller_admin.NewBadgeController(badgeService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) avatarMiddleware := middleware.NewAvatarMiddleware(serviceConf, uploaderService) shortIDMiddleware := middleware.NewShortIDMiddleware(siteInfoCommonService) templateRenderController := templaterender.NewTemplateRenderController(questionService, userService, tagService, answerService, commentService, siteInfoCommonService, questionRepo) - templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService) + templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService, eventQueueService, userService) templateRouter := router.NewTemplateRouter(templateController, templateRenderController, siteInfoController, authUserMiddleware) connectorController := controller.NewConnectorController(siteInfoCommonService, emailService, userExternalLoginService) userCenterLoginService := user_external_login2.NewUserCenterLoginService(userRepo, userCommon, userExternalLoginRepo, userActiveActivityRepo, siteInfoCommonService) diff --git a/docs/docs.go b/docs/docs.go index cf1264d14..cfefe0ed9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -154,6 +154,119 @@ const docTemplate = `{ } } }, + "/answer/admin/api/badge/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update badge status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "update badge status", + "parameters": [ + { + "description": "UpdateBadgeStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "list all badges by page", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "", + "active", + "inactive" + ], + "type": "string", + "description": "badge status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "search param", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListPagedResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/dashboard": { "get": { "security": [ @@ -2184,8 +2297,253 @@ const docTemplate = `{ }, { "type": "string", - "description": "page_size", - "name": "page_size", + "description": "page_size", + "name": "page_size", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/answer/api/v1/answer/recover": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "recover deleted answer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Answer" + ], + "summary": "recover answer", + "parameters": [ + { + "description": "answer", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.RecoverAnswerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/api/v1/badge": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge info", + "parameters": [ + { + "type": "string", + "default": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/awards/page": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge award list", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "badge id", + "name": "badge_id", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "only list the award by username", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards/recent": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", "in": "query", "required": true } @@ -2194,20 +2552,35 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] } } } } }, - "/answer/api/v1/answer/recover": { - "post": { + "/answer/api/v1/badges": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "recover deleted answer", + "description": "list all badges group by group", "consumes": [ "application/json" ], @@ -2215,25 +2588,29 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Answer" - ], - "summary": "recover answer", - "parameters": [ - { - "description": "answer", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.RecoverAnswerReq" - } - } + "api-badge" ], + "summary": "list all badges group by group", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListResp" + } + } + } + } + ] } } } @@ -4410,7 +4787,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetTagResp" + "$ref": "#/definitions/schema.GetTagBasicResp" } } } @@ -5380,7 +5757,7 @@ const docTemplate = `{ }, "/answer/api/v1/tags": { "get": { - "description": "get tags list", + "description": "get tags list by slug name", "produces": [ "application/json" ], @@ -5404,7 +5781,22 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetTagBasicResp" + } + } + } + } + ] } } } @@ -6571,14 +6963,14 @@ const docTemplate = `{ }, "/custom.css": { "get": { - "description": "get site robots information", + "description": "get site custom CSS", "produces": [ - "application/json" + "text/css" ], "tags": [ "site" ], - "summary": "get site robots information", + "summary": "get site custom CSS", "responses": { "200": { "description": "OK", @@ -6884,6 +7276,19 @@ const docTemplate = `{ } } }, + "entity.BadgeLevel": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "BadgeLevelBronze", + "BadgeLevelSilver", + "BadgeLevelGold" + ] + }, "handler.RespBody": { "type": "object", "properties": { @@ -7327,6 +7732,50 @@ const docTemplate = `{ } } }, + "schema.BadgeListInfo": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "earned": { + "description": "badge earned count", + "type": "boolean" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.BadgeStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ], + "x-enum-varnames": [ + "BadgeStatusActive", + "BadgeStatusInactive" + ] + }, "schema.CloseQuestionReq": { "type": "object", "required": [ @@ -7578,6 +8027,112 @@ const docTemplate = `{ } } }, + "schema.GetBadgeInfoResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned_count": { + "description": "badge earned count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "is_single": { + "description": "badge is single or multiple", + "type": "boolean" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.GetBadgeListPagedResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned": { + "description": "badge earned count", + "type": "boolean" + }, + "group_name": { + "description": "badge group name", + "type": "string" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, + "schema.GetBadgeListResp": { + "type": "object", + "properties": { + "badges": { + "description": "badge list info", + "type": "array", + "items": { + "$ref": "#/definitions/schema.BadgeListInfo" + } + }, + "group_name": { + "description": "badge group name", + "type": "string" + } + } + }, "schema.GetCommentPersonalWithPageResp": { "type": "object", "properties": { @@ -8242,6 +8797,23 @@ const docTemplate = `{ } } }, + "schema.GetTagBasicResp": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "recommend": { + "type": "boolean" + }, + "reserved": { + "type": "boolean" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.GetTagPageResp": { "type": "object", "properties": { @@ -8469,6 +9041,35 @@ const docTemplate = `{ } } }, + "schema.GetUserBadgeAwardListResp": { + "type": "object", + "properties": { + "earned_count": { + "description": "badge award count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, "schema.GetUserNotificationConfigResp": { "type": "object", "properties": { @@ -9819,7 +10420,7 @@ const docTemplate = `{ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9828,7 +10429,7 @@ const docTemplate = `{ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9842,7 +10443,7 @@ const docTemplate = `{ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9851,7 +10452,7 @@ const docTemplate = `{ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9859,6 +10460,20 @@ const docTemplate = `{ } } }, + "schema.SiteWriteTag": { + "type": "object", + "required": [ + "slug_name" + ], + "properties": { + "display_name": { + "type": "string" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.TagItem": { "type": "object", "properties": { @@ -10004,6 +10619,27 @@ const docTemplate = `{ } } }, + "schema.UpdateBadgeStatusReq": { + "type": "object", + "required": [ + "id", + "status" + ], + "properties": { + "id": { + "description": "badge id", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, "schema.UpdateCommentReq": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 1e93d2b08..234d2f135 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -124,6 +124,119 @@ } } }, + "/answer/admin/api/badge/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update badge status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "update badge status", + "parameters": [ + { + "description": "UpdateBadgeStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "list all badges by page", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "", + "active", + "inactive" + ], + "type": "string", + "description": "badge status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "search param", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListPagedResp" + } + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/dashboard": { "get": { "security": [ @@ -2154,8 +2267,253 @@ }, { "type": "string", - "description": "page_size", - "name": "page_size", + "description": "page_size", + "name": "page_size", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/answer/api/v1/answer/recover": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "recover deleted answer", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Answer" + ], + "summary": "recover answer", + "parameters": [ + { + "description": "answer", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.RecoverAnswerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/api/v1/badge": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge info", + "parameters": [ + { + "type": "string", + "default": "string", + "description": "id", + "name": "id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/awards/page": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get badge award list", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "badge id", + "name": "badge_id", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "only list the award by username", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetBadgeInfoResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/badge/user/awards/recent": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get user badge award list", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "api-badge" + ], + "summary": "get user badge award list", + "parameters": [ + { + "type": "string", + "description": "user name", + "name": "username", "in": "query", "required": true } @@ -2164,20 +2522,35 @@ "200": { "description": "OK", "schema": { - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserBadgeAwardListResp" + } + } + } + } + ] } } } } }, - "/answer/api/v1/answer/recover": { - "post": { + "/answer/api/v1/badges": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "recover deleted answer", + "description": "list all badges group by group", "consumes": [ "application/json" ], @@ -2185,25 +2558,29 @@ "application/json" ], "tags": [ - "Answer" - ], - "summary": "recover answer", - "parameters": [ - { - "description": "answer", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.RecoverAnswerReq" - } - } + "api-badge" ], + "summary": "list all badges group by group", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListResp" + } + } + } + } + ] } } } @@ -4380,7 +4757,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetTagResp" + "$ref": "#/definitions/schema.GetTagBasicResp" } } } @@ -5350,7 +5727,7 @@ }, "/answer/api/v1/tags": { "get": { - "description": "get tags list", + "description": "get tags list by slug name", "produces": [ "application/json" ], @@ -5374,7 +5751,22 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetTagBasicResp" + } + } + } + } + ] } } } @@ -6541,14 +6933,14 @@ }, "/custom.css": { "get": { - "description": "get site robots information", + "description": "get site custom CSS", "produces": [ - "application/json" + "text/css" ], "tags": [ "site" ], - "summary": "get site robots information", + "summary": "get site custom CSS", "responses": { "200": { "description": "OK", @@ -6854,6 +7246,19 @@ } } }, + "entity.BadgeLevel": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-varnames": [ + "BadgeLevelBronze", + "BadgeLevelSilver", + "BadgeLevelGold" + ] + }, "handler.RespBody": { "type": "object", "properties": { @@ -7297,6 +7702,50 @@ } } }, + "schema.BadgeListInfo": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "earned": { + "description": "badge earned count", + "type": "boolean" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.BadgeStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ], + "x-enum-varnames": [ + "BadgeStatusActive", + "BadgeStatusInactive" + ] + }, "schema.CloseQuestionReq": { "type": "object", "required": [ @@ -7548,6 +7997,112 @@ } } }, + "schema.GetBadgeInfoResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned_count": { + "description": "badge earned count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "is_single": { + "description": "badge is single or multiple", + "type": "boolean" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, + "schema.GetBadgeListPagedResp": { + "type": "object", + "properties": { + "award_count": { + "description": "badge award count", + "type": "integer" + }, + "description": { + "description": "badge description", + "type": "string" + }, + "earned": { + "description": "badge earned count", + "type": "boolean" + }, + "group_name": { + "description": "badge group name", + "type": "string" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, + "schema.GetBadgeListResp": { + "type": "object", + "properties": { + "badges": { + "description": "badge list info", + "type": "array", + "items": { + "$ref": "#/definitions/schema.BadgeListInfo" + } + }, + "group_name": { + "description": "badge group name", + "type": "string" + } + } + }, "schema.GetCommentPersonalWithPageResp": { "type": "object", "properties": { @@ -8212,6 +8767,23 @@ } } }, + "schema.GetTagBasicResp": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "recommend": { + "type": "boolean" + }, + "reserved": { + "type": "boolean" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.GetTagPageResp": { "type": "object", "properties": { @@ -8439,6 +9011,35 @@ } } }, + "schema.GetUserBadgeAwardListResp": { + "type": "object", + "properties": { + "earned_count": { + "description": "badge award count", + "type": "integer" + }, + "icon": { + "description": "badge icon", + "type": "string" + }, + "id": { + "description": "badge id", + "type": "string" + }, + "level": { + "description": "badge level", + "allOf": [ + { + "$ref": "#/definitions/entity.BadgeLevel" + } + ] + }, + "name": { + "description": "badge name", + "type": "string" + } + } + }, "schema.GetUserNotificationConfigResp": { "type": "object", "properties": { @@ -8809,7 +9410,7 @@ "enum": [ "newest", "active", - "frequent", + "hot", "score", "unanswered" ] @@ -9789,7 +10390,7 @@ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9798,7 +10399,7 @@ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9812,7 +10413,7 @@ "recommend_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "required_tag": { @@ -9821,7 +10422,7 @@ "reserved_tags": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/schema.SiteWriteTag" } }, "restrict_answer": { @@ -9829,6 +10430,20 @@ } } }, + "schema.SiteWriteTag": { + "type": "object", + "required": [ + "slug_name" + ], + "properties": { + "display_name": { + "type": "string" + }, + "slug_name": { + "type": "string" + } + } + }, "schema.TagItem": { "type": "object", "properties": { @@ -9974,6 +10589,27 @@ } } }, + "schema.UpdateBadgeStatusReq": { + "type": "object", + "required": [ + "id", + "status" + ], + "properties": { + "id": { + "description": "badge id", + "type": "string" + }, + "status": { + "description": "badge status", + "allOf": [ + { + "$ref": "#/definitions/schema.BadgeStatus" + } + ] + } + } + }, "schema.UpdateCommentReq": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 93f8116af..1a5979e6a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -32,6 +32,16 @@ definitions: minimum: 1 type: integer type: object + entity.BadgeLevel: + enum: + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - BadgeLevelBronze + - BadgeLevelSilver + - BadgeLevelGold handler.RespBody: properties: code: @@ -342,6 +352,36 @@ definitions: maxLength: 100 type: string type: object + schema.BadgeListInfo: + properties: + award_count: + description: badge award count + type: integer + earned: + description: badge earned count + type: boolean + icon: + description: badge icon + type: string + id: + description: badge id + type: string + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + type: object + schema.BadgeStatus: + enum: + - active + - inactive + type: string + x-enum-varnames: + - BadgeStatusActive + - BadgeStatusInactive schema.CloseQuestionReq: properties: close_msg: @@ -513,6 +553,77 @@ definitions: description: if user is followed object will be true,otherwise false type: boolean type: object + schema.GetBadgeInfoResp: + properties: + award_count: + description: badge award count + type: integer + description: + description: badge description + type: string + earned_count: + description: badge earned count + type: integer + icon: + description: badge icon + type: string + id: + description: badge id + type: string + is_single: + description: badge is single or multiple + type: boolean + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + type: object + schema.GetBadgeListPagedResp: + properties: + award_count: + description: badge award count + type: integer + description: + description: badge description + type: string + earned: + description: badge earned count + type: boolean + group_name: + description: badge group name + type: string + icon: + description: badge icon + type: string + id: + description: badge id + type: string + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + status: + allOf: + - $ref: '#/definitions/schema.BadgeStatus' + description: badge status + type: object + schema.GetBadgeListResp: + properties: + badges: + description: badge list info + items: + $ref: '#/definitions/schema.BadgeListInfo' + type: array + group_name: + description: badge group name + type: string + type: object schema.GetCommentPersonalWithPageResp: properties: answer_id: @@ -983,6 +1094,17 @@ definitions: terms_of_service_parsed_text: type: string type: object + schema.GetTagBasicResp: + properties: + display_name: + type: string + recommend: + type: boolean + reserved: + type: boolean + slug_name: + type: string + type: object schema.GetTagPageResp: properties: created_at: @@ -1139,6 +1261,25 @@ definitions: activation_url: type: string type: object + schema.GetUserBadgeAwardListResp: + properties: + earned_count: + description: badge award count + type: integer + icon: + description: badge icon + type: string + id: + description: badge id + type: string + level: + allOf: + - $ref: '#/definitions/entity.BadgeLevel' + description: badge level + name: + description: badge name + type: string + type: object schema.GetUserNotificationConfigResp: properties: all_new_question: @@ -1401,7 +1542,7 @@ definitions: enum: - newest - active - - frequent + - hot - score - unanswered type: string @@ -2071,13 +2212,13 @@ definitions: properties: recommend_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array required_tag: type: boolean reserved_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array restrict_answer: type: boolean @@ -2086,17 +2227,26 @@ definitions: properties: recommend_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array required_tag: type: boolean reserved_tags: items: - type: string + $ref: '#/definitions/schema.SiteWriteTag' type: array restrict_answer: type: boolean type: object + schema.SiteWriteTag: + properties: + display_name: + type: string + slug_name: + type: string + required: + - slug_name + type: object schema.TagItem: properties: display_name: @@ -2197,6 +2347,19 @@ definitions: url_title: type: string type: object + schema.UpdateBadgeStatusReq: + properties: + id: + description: badge id + type: string + status: + allOf: + - $ref: '#/definitions/schema.BadgeStatus' + description: badge status + required: + - id + - status + type: object schema.UpdateCommentReq: properties: captcha_code: @@ -2814,6 +2977,75 @@ paths: summary: update answer status tags: - admin + /answer/admin/api/badge/status: + put: + consumes: + - application/json + description: update badge status + parameters: + - description: UpdateBadgeStatusReq + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UpdateBadgeStatusReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update badge status + tags: + - AdminBadge + /answer/admin/api/badges: + get: + consumes: + - application/json + description: list all badges by page + parameters: + - description: page + in: query + name: page + type: integer + - description: page size + in: query + name: page_size + type: integer + - description: badge status + enum: + - "" + - active + - inactive + in: query + name: status + type: string + - description: search param + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetBadgeListPagedResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: list all badges by page + tags: + - AdminBadge /answer/admin/api/dashboard: get: consumes: @@ -4063,6 +4295,159 @@ paths: summary: recover answer tags: - Answer + /answer/api/v1/badge: + get: + consumes: + - application/json + description: get badge info + parameters: + - default: string + description: id + in: query + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetBadgeInfoResp' + type: object + security: + - ApiKeyAuth: [] + summary: get badge info + tags: + - api-badge + /answer/api/v1/badge/awards/page: + get: + consumes: + - application/json + description: get badge award list + parameters: + - description: page + in: query + name: page + type: integer + - description: page size + in: query + name: page_size + type: integer + - description: badge id + in: query + name: badge_id + required: true + type: string + - description: only list the award by username + in: query + name: username + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.GetBadgeInfoResp' + type: object + security: + - ApiKeyAuth: [] + summary: get badge award list + tags: + - api-badge + /answer/api/v1/badge/user/awards: + get: + consumes: + - application/json + description: get user badge award list + parameters: + - description: user name + in: query + name: username + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetUserBadgeAwardListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get user badge award list + tags: + - api-badge + /answer/api/v1/badge/user/awards/recent: + get: + consumes: + - application/json + description: get user badge award list + parameters: + - description: user name + in: query + name: username + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetUserBadgeAwardListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get user badge award list + tags: + - api-badge + /answer/api/v1/badges: + get: + consumes: + - application/json + description: list all badges group by group + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetBadgeListResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: list all badges group by group + tags: + - api-badge /answer/api/v1/collection/switch: post: consumes: @@ -5382,7 +5767,7 @@ paths: - properties: data: items: - $ref: '#/definitions/schema.GetTagResp' + $ref: '#/definitions/schema.GetTagBasicResp' type: array type: object security: @@ -5965,7 +6350,7 @@ paths: - Tag /answer/api/v1/tags: get: - description: get tags list + description: get tags list by slug name parameters: - collectionFormat: csv description: string collection @@ -5980,7 +6365,14 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handler.RespBody' + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetTagBasicResp' + type: array + type: object summary: get tags list tags: - Tag @@ -6677,15 +7069,15 @@ paths: - Activity /custom.css: get: - description: get site robots information + description: get site custom CSS produces: - - application/json + - text/css responses: "200": description: OK schema: type: string - summary: get site robots information + summary: get site custom CSS tags: - site /installation/base-info: diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index a123d70d7..4de696589 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -146,6 +146,8 @@ backend: common: invalid_url: other: Invalid URL. + status_invalid: + other: Invalid status. password: space_invalid: other: Password cannot contain spaces. @@ -312,6 +314,9 @@ backend: site_info: config_not_found: other: Site config not found. + badge: + object_not_found: + other: Badge object not found reason: spam: name: @@ -421,7 +426,7 @@ backend: tags_title: other: Tags no_description: - other: The tag has no description. + other: The tag has no description. notification: action: update_question: @@ -460,6 +465,8 @@ backend: other: upvoted comment invited_you_to_answer: other: invited you to answer + earned_badge: + other: You've earned the "{{.BadgeName}}" badge email_tpl: change_email: title: @@ -526,6 +533,263 @@ backend: reaction: tooltip: other: "{{ .Names }} and {{ .Count }} more..." + badge: + default_badges: + autobiographer: + name: + other: Autobiographer + desc: + other: Filled out [profile] information. + certified: + name: + other: Certified + desc: + other: Completed our new user tutorial. + editor: + name: + other: Editor + desc: + other: First post edit. + first_flag: + name: + other: First Flag + desc: + other: First flagged a post. + first_upvote: + name: + other: First Upvote + desc: + other: First up voted a post. + first_link: + name: + other: First Link + desc: + other: First dirst added a link to another post. + first_reaction: + name: + other: First Reaction + desc: + other: First reacted to the post. + first_share: + name: + other: First Share + desc: + other: First shared a post. + scholar: + name: + other: Scholar + desc: + other: Asked a question and accepted an answer. + commentator: + name: + other: Commentator + desc: + other: Leave 5 comments. + new_user_of_the_month: + name: + other: New User of the Month + desc: + other: Outstanding contributions in their first month. + read_guidelines: + name: + other: Read Guidelines + desc: + other: Read the [community guidelines]. + reader: + name: + other: Reader + desc: + other: Read every answers in a topic with more than 10 answers. + welcome: + name: + other: Welcome + desc: + other: Received a up vote. + nice_share: + name: + other: Nice Share + desc: + other: Shared a post with 25 unique visitors. + good_share: + name: + other: Good Share + desc: + other: Shared a post with 300 unique visitors. + great_share: + name: + other: Great Share + desc: + other: Shared a post with 1000 unique visitors. + out_of_love: + name: + other: Out of Love + desc: + other: Used 50 up votes in a day. + higher_love: + name: + other: Higher Love + desc: + other: Used 50 up votes in a day 5 times. + crazy_in_love: + name: + other: Crazy in Love + desc: + other: Used 50 up votes in a day 20 times. + promoter: + name: + other: Promoter + desc: + other: Invited a user. + campaigner: + name: + other: Campaigner + desc: + other: Invited 3 basic users. + champion: + name: + other: Champion + desc: + other: Invited 5 members. + thank_you: + name: + other: Thank You + desc: + other: Has 20 up voted posts and gave 10 up votes. + gives_back: + name: + other: Gives Back + desc: + other: Has 100 up voted posts and gave 100 up votes. + empathetic: + name: + other: Empathetic + desc: + other: Has 500 up voted posts and gave 1000 up votes. + enthusiast: + name: + other: Enthusiast + desc: + other: Visited 10 consecutive days. + aficionado: + name: + other: Aficionado + desc: + other: Visited 100 consecutive days. + devotee: + name: + other: Devotee + desc: + other: Visited 365 consecutive days. + anniversary: + name: + other: Anniversary + desc: + other: Active member for a year, posted at least once. + appreciated: + name: + other: Appreciated + desc: + other: Received 1 up vote on 20 posts. + respected: + name: + other: Respected + desc: + other: Received 2 up votes on 100 posts. + admired: + name: + other: Admired + desc: + other: Received 5 up votes on 300 posts. + solved: + name: + other: Solved + desc: + other: Have an answer be accepted. + guidance_counsellor: + name: + other: Guidance Counsellor + desc: + other: Have 10 answers be accepted. + know_it_all: + name: + other: Know-it-All + desc: + other: Have 50 answers be accepted. + solution_institution: + name: + other: Solution Institution + desc: + other: Have 150 answers be accepted. + nice_answer: + name: + other: Nice Answer + desc: + other: Answer score of 10 or more. + good_answer: + name: + other: Good Answer + desc: + other: Answer score of 25 or more. + great_answer: + name: + other: Great Answer + desc: + other: Answer score of 50 or more. + nice_question: + name: + other: Nice Question + desc: + other: Question score of 10 or more. + good_question: + name: + other: Good Question + desc: + other: Question score of 25 or more. + great_question: + name: + other: Great Question + desc: + other: Question score of 50 or more. + popular_question: + name: + other: Popular Question + desc: + other: Question with 500 views. + notable_question: + name: + other: Notable Question + desc: + other: Question with 1,000 views. + famous_question: + name: + other: Famous Question + desc: + other: Question with 5,000 views. + popular_link: + name: + other: Popular Link + desc: + other: Posted an external link with 50 clicks. + hot_link: + name: + other: Hot Link + desc: + other: Posted an external link with 300 clicks. + famous_link: + name: + other: Famous Link + desc: + other: Posted an external link with 100 clicks. + default_badge_groups: + getting_started: + name: + other: Getting Started + community: + name: + other: Community + posting: + name: + other: Posting # The following fields are used for interface presentation(Front-end) ui: @@ -1445,6 +1709,7 @@ ui: questions: Questions answers: Answers users: Users + badges: Badges flags: Flags settings: Settings general: General @@ -1870,7 +2135,20 @@ ui: msg: should_be_number: the input should be number number_larger_1: number should be equal or larger than 1 - + badges: + action: Action + active: Active + all: All + awards: Awards + deactivate: Deactivate + filter: + placeholder: Filter by name, badge:id + group: Group + inactive: Inactive + name: Name + show_logs: Show logs + status: Status + title: Badges form: optional: (optional) empty: cannot be empty diff --git a/internal/base/constant/cache_key.go b/internal/base/constant/cache_key.go index 4135b53c8..987798d19 100644 --- a/internal/base/constant/cache_key.go +++ b/internal/base/constant/cache_key.go @@ -50,4 +50,6 @@ const ( NewQuestionNotificationLimitMax = 50 RateLimitCacheKeyPrefix = "answer:rate-limit:" RateLimitCacheTime = 5 * time.Minute + RedDotCacheKey = "answer:red-dot:%s:%s" + RedDotCacheTime = 30 * 24 * time.Hour ) diff --git a/internal/base/constant/event.go b/internal/base/constant/event.go new file mode 100644 index 000000000..f7fd8412a --- /dev/null +++ b/internal/base/constant/event.go @@ -0,0 +1,75 @@ +/* + * 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 constant + +// EventType event type. It is used to define the type of event. Such as object.action +type EventType string + +// event object +const ( + eventQuestion = "question" + eventAnswer = "answer" + eventComment = "comment" + eventUser = "user" +) + +// event action +const ( + eventCreate = "create" + eventUpdate = "update" + eventDelete = "delete" + eventVote = "vote" + eventAccept = "accept" // only question have the accept event + eventShare = "share" // the object share link has been clicked + eventFlag = "flag" + eventReact = "react" +) + +const ( + EventUserUpdate EventType = eventUser + "." + eventUpdate + EventUserShare EventType = eventUser + "." + eventShare +) + +const ( + EventQuestionCreate EventType = eventQuestion + "." + eventCreate + EventQuestionUpdate EventType = eventQuestion + "." + eventUpdate + EventQuestionDelete EventType = eventQuestion + "." + eventDelete + EventQuestionVote EventType = eventQuestion + "." + eventVote + EventQuestionAccept EventType = eventQuestion + "." + eventAccept + EventQuestionFlag EventType = eventQuestion + "." + eventFlag + EventQuestionReact EventType = eventQuestion + "." + eventReact +) + +const ( + EventAnswerCreate EventType = eventAnswer + "." + eventCreate + EventAnswerUpdate EventType = eventAnswer + "." + eventUpdate + EventAnswerDelete EventType = eventAnswer + "." + eventDelete + EventAnswerVote EventType = eventAnswer + "." + eventVote + EventAnswerFlag EventType = eventAnswer + "." + eventFlag + EventAnswerReact EventType = eventAnswer + "." + eventReact +) + +const ( + EventCommentCreate EventType = eventComment + "." + eventCreate + EventCommentUpdate EventType = eventComment + "." + eventUpdate + EventCommentDelete EventType = eventComment + "." + eventDelete + EventCommentVote EventType = eventComment + "." + eventVote + EventCommentFlag EventType = eventComment + "." + eventFlag +) diff --git a/internal/base/constant/notification.go b/internal/base/constant/notification.go index ceebe7de8..9a7762d8e 100644 --- a/internal/base/constant/notification.go +++ b/internal/base/constant/notification.go @@ -56,6 +56,8 @@ const ( NotificationYourCommentWasDeleted = "notification.action.your_comment_was_deleted" // NotificationInvitedYouToAnswer invited you to answer NotificationInvitedYouToAnswer = "notification.action.invited_you_to_answer" + // NotificationEarnedBadge earned badge + NotificationEarnedBadge = "notification.action.earned_badge" ) type NotificationChannelKey string @@ -71,6 +73,12 @@ const ( EmailChannel NotificationChannelKey = "email" ) +const ( + NotificationTypeInbox = "inbox" + NotificationTypeAchievement = "achievement" + NotificationTypeBadgeAchievement = "badge" +) + var ( NotificationMsgTypeMapping = map[string]int{ NotificationUpdateQuestion: 1, diff --git a/internal/base/constant/object_type.go b/internal/base/constant/object_type.go index b3e3883df..e4ac3d20c 100644 --- a/internal/base/constant/object_type.go +++ b/internal/base/constant/object_type.go @@ -27,6 +27,8 @@ const ( CollectionObjectType = "collection" CommentObjectType = "comment" ReportObjectType = "report" + BadgeObjectType = "badge" + BadgeAwardObjectType = "badge_award" ) var ( @@ -38,15 +40,19 @@ var ( CollectionObjectType: 6, CommentObjectType: 7, ReportObjectType: 8, + BadgeObjectType: 9, + BadgeAwardObjectType: 10, } ObjectTypeNumberMapping = map[int]string{ - 1: QuestionObjectType, - 2: AnswerObjectType, - 3: TagObjectType, - 4: UserObjectType, - 6: CollectionObjectType, - 7: CommentObjectType, - 8: ReportObjectType, + 1: QuestionObjectType, + 2: AnswerObjectType, + 3: TagObjectType, + 4: UserObjectType, + 6: CollectionObjectType, + 7: CommentObjectType, + 8: ReportObjectType, + 9: BadgeObjectType, + 10: BadgeAwardObjectType, } ) diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 66ef1bed0..24d7ab5f9 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -106,6 +106,8 @@ const ( AddBulkUsersAmountError = "error.user.add_bulk_users_amount_error" InvalidURLError = "error.common.invalid_url" MetaObjectNotFound = "error.meta.object_not_found" + BadgeObjectNotFound = "error.badge.object_not_found" + StatusInvalid = "error.common.status_invalid" ) // user external login reasons diff --git a/internal/controller/badge_controller.go b/internal/controller/badge_controller.go new file mode 100644 index 000000000..65b594072 --- /dev/null +++ b/internal/controller/badge_controller.go @@ -0,0 +1,158 @@ +/* + * 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 controller + +import ( + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/middleware" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/pkg/uid" + "github.com/gin-gonic/gin" +) + +type BadgeController struct { + badgeService *badge.BadgeService + badgeAwardService *badge.BadgeAwardService +} + +func NewBadgeController( + badgeService *badge.BadgeService, + badgeAwardService *badge.BadgeAwardService) *BadgeController { + return &BadgeController{ + badgeService: badgeService, + badgeAwardService: badgeAwardService, + } +} + +// GetBadgeList list all badges +// @Summary list all badges group by group +// @Description list all badges group by group +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} handler.RespBody{data=[]schema.GetBadgeListResp} +// @Router /answer/api/v1/badges [get] +func (b *BadgeController) GetBadgeList(ctx *gin.Context) { + userID := middleware.GetLoginUserIDFromContext(ctx) + resp, err := b.badgeService.ListByGroup(ctx, userID) + handler.HandleResponse(ctx, err, resp) +} + +// GetBadgeInfo get badge info +// @Summary get badge info +// @Description get badge info +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id query string true "id" default(string) +// @Success 200 {object} handler.RespBody{data=schema.GetBadgeInfoResp} +// @Router /answer/api/v1/badge [get] +func (b *BadgeController) GetBadgeInfo(ctx *gin.Context) { + id := ctx.Query("id") + id = uid.DeShortID(id) + + userID := middleware.GetLoginUserIDFromContext(ctx) + resp, err := b.badgeService.GetBadgeInfo(ctx, id, userID) + handler.HandleResponse(ctx, err, resp) +} + +// GetBadgeAwardList get badge award list +// @Summary get badge award list +// @Description get badge award list +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Param badge_id query string true "badge id" +// @Param username query string false "only list the award by username" +// @Success 200 {object} handler.RespBody{data=schema.GetBadgeInfoResp} +// @Router /answer/api/v1/badge/awards/page [get] +func (b *BadgeController) GetBadgeAwardList(ctx *gin.Context) { + req := &schema.GetBadgeAwardWithPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.BadgeID = uid.DeShortID(req.BadgeID) + + resp, total, err := b.badgeAwardService.GetBadgeAwardList(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} + +// GetAllBadgeAwardListByUsername get user badge award list +// @Summary get user badge award list +// @Description get user badge award list +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param username query string true "user name" +// @Success 200 {object} handler.RespBody{data=[]schema.GetUserBadgeAwardListResp} +// @Router /answer/api/v1/badge/user/awards [get] +func (b *BadgeController) GetAllBadgeAwardListByUsername(ctx *gin.Context) { + req := &schema.GetUserBadgeAwardListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, total, err := b.badgeAwardService.GetUserBadgeAwardList(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} + +// GetRecentBadgeAwardListByUsername get user badge award list +// @Summary get user badge award list +// @Description get user badge award list +// @Tags api-badge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param username query string true "user name" +// @Success 200 {object} handler.RespBody{data=[]schema.GetUserBadgeAwardListResp} +// @Router /answer/api/v1/badge/user/awards/recent [get] +func (b *BadgeController) GetRecentBadgeAwardListByUsername(ctx *gin.Context) { + req := &schema.GetUserBadgeAwardListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.Limit = 10 + + resp, total, err := b.badgeAwardService.GetUserRecentBadgeAwardList(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 9eb64c585..8fad918a1 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -51,4 +51,5 @@ var ProviderSetController = wire.NewSet( NewCaptchaController, NewMetaController, NewEmbedController, + NewBadgeController, ) diff --git a/internal/controller/notification_controller.go b/internal/controller/notification_controller.go index 15796b9c4..952c262e9 100644 --- a/internal/controller/notification_controller.go +++ b/internal/controller/notification_controller.go @@ -105,8 +105,8 @@ func (nc *NotificationController) ClearRedDot(ctx *gin.Context) { req.CanReviewAnswer = canList[1] req.CanReviewTag = canList[2] - RedDot, err := nc.notificationService.ClearRedDot(ctx, req) - handler.HandleResponse(ctx, err, RedDot) + resp, err := nc.notificationService.ClearRedDot(ctx, req) + handler.HandleResponse(ctx, err, resp) } // ClearUnRead @@ -125,7 +125,7 @@ func (nc *NotificationController) ClearUnRead(ctx *gin.Context) { return } userID := middleware.GetLoginUserIDFromContext(ctx) - err := nc.notificationService.ClearUnRead(ctx, userID, req.TypeStr) + err := nc.notificationService.ClearUnRead(ctx, userID, req.NotificationType) handler.HandleResponse(ctx, err, gin.H{}) } diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 09f5bffcb..a786f646d 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -22,6 +22,8 @@ package controller import ( "encoding/json" "fmt" + "github.com/apache/incubator-answer/internal/service/content" + "github.com/apache/incubator-answer/internal/service/event_queue" "github.com/apache/incubator-answer/plugin" "html/template" "net/http" @@ -54,12 +56,16 @@ type TemplateController struct { cssPath string templateRenderController *templaterender.TemplateRenderController siteInfoService siteinfo_common.SiteInfoCommonService + eventQueueService event_queue.EventQueueService + userService *content.UserService } // NewTemplateController new controller func NewTemplateController( templateRenderController *templaterender.TemplateRenderController, siteInfoService siteinfo_common.SiteInfoCommonService, + eventQueueService event_queue.EventQueueService, + userService *content.UserService, ) *TemplateController { script, css := GetStyle() return &TemplateController{ @@ -67,6 +73,8 @@ func NewTemplateController( cssPath: css, templateRenderController: templateRenderController, siteInfoService: siteInfoService, + eventQueueService: eventQueueService, + userService: userService, } } func GetStyle() (script []string, css string) { @@ -271,6 +279,7 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) { id := ctx.Param("id") title := ctx.Param("title") answerid := ctx.Param("answerid") + shareUsername := ctx.Query("share") if checker.IsQuestionsIgnorePath(id) { // if id == "ask" { file, err := ui.Build.ReadFile("build/index.html") @@ -291,6 +300,13 @@ func (tc *TemplateController) QuestionInfo(ctx *gin.Context) { tc.Page404(ctx) return } + if len(shareUsername) > 0 { + userInfo, err := tc.userService.GetOtherUserInfoByUsername( + ctx, &schema.GetOtherUserInfoByUsernameReq{Username: shareUsername}) + if err == nil { + tc.eventQueueService.Send(ctx, schema.NewEvent(constant.EventUserShare, userInfo.ID)) + } + } encodeTitle := htmltext.UrlTitle(detail.Title) if encodeTitle == title { correctTitle = true diff --git a/internal/controller_admin/badge_controller.go b/internal/controller_admin/badge_controller.go new file mode 100644 index 000000000..4a44f1764 --- /dev/null +++ b/internal/controller_admin/badge_controller.go @@ -0,0 +1,86 @@ +/* + * 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 controller_admin + +import ( + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/gin-gonic/gin" +) + +type BadgeController struct { + badgeService *badge.BadgeService +} + +func NewBadgeController(badgeService *badge.BadgeService) *BadgeController { + return &BadgeController{ + badgeService: badgeService, + } +} + +// GetBadgeList list all badges by page +// @Summary list all badges by page +// @Description list all badges by page +// @Tags AdminBadge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Param status query string false "badge status" Enums(, active, inactive) +// @Param q query string false "search param" +// @Success 200 {object} handler.RespBody{data=[]schema.GetBadgeListPagedResp} +// @Router /answer/admin/api/badges [get] +func (b *BadgeController) GetBadgeList(ctx *gin.Context) { + req := &schema.GetBadgeListPagedReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, total, err := b.badgeService.ListPaged(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + + handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp)) +} + +// UpdateBadgeStatus update badge status +// @Summary update badge status +// @Description update badge status +// @Tags AdminBadge +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.UpdateBadgeStatusReq true "UpdateBadgeStatusReq" +// @Success 200 {object} handler.RespBody +// @Router /answer/admin/api/badge/status [put] +func (b *BadgeController) UpdateBadgeStatus(ctx *gin.Context) { + req := &schema.UpdateBadgeStatusReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := b.badgeService.UpdateStatus(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/controller.go b/internal/controller_admin/controller.go index de87d105e..ebf32cbfc 100644 --- a/internal/controller_admin/controller.go +++ b/internal/controller_admin/controller.go @@ -28,4 +28,5 @@ var ProviderSetController = wire.NewSet( NewSiteInfoController, NewRoleController, NewPluginController, + NewBadgeController, ) diff --git a/internal/entity/badge_award_entity.go b/internal/entity/badge_award_entity.go new file mode 100644 index 000000000..5ba475cd9 --- /dev/null +++ b/internal/entity/badge_award_entity.go @@ -0,0 +1,70 @@ +/* + * 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 entity + +import "time" + +const ( + IsBadgeNotDeleted = 0 + IsBadgeDeleted = 1 + + BadgeOnceAwardKey = "0" +) + +// BadgeAward badge_award +type BadgeAward struct { + ID string `xorm:"not null pk BIGINT(20) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + UserID string `xorm:"not null index BIGINT(20) user_id"` + BadgeID string `xorm:"not null index BIGINT(20) badge_id"` + AwardKey string `xorm:"not null index VARCHAR(64) award_key"` + BadgeGroupID int64 `xorm:"not null index BIGINT(20) badge_group_id"` + IsBadgeDeleted int8 `xorm:"not null TINYINT(1) is_badge_deleted"` +} + +// TableName badge_award table name +func (BadgeAward) TableName() string { + return "badge_award" +} + +type BadgeEarnedCount struct { + BadgeID string `xorm:"badge_id"` + EarnedCount int64 `xorm:"earned_count"` +} + +// TableName badge_award table name +func (BadgeEarnedCount) TableName() string { + return "badge_award" +} + +type BadgeAwardRecent struct { + Created time.Time `xorm:"created"` + UserID string `xorm:"user_id"` + BadgeID string `xorm:"badge_id"` + AwardKey string `xorm:"award_key"` + EarnedCount int64 `xorm:"earned_count"` + IsBadgeDeleted int8 `xorm:"is_badge_deleted"` +} + +// TableName badge_award table name +func (BadgeAwardRecent) TableName() string { + return "badge_award" +} diff --git a/internal/entity/badge_entity.go b/internal/entity/badge_entity.go new file mode 100644 index 000000000..a370e2750 --- /dev/null +++ b/internal/entity/badge_entity.go @@ -0,0 +1,71 @@ +/* + * 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 entity + +import ( + "github.com/tidwall/gjson" + "time" +) + +type BadgeLevel int + +const ( + BadgeStatusActive = 1 + BadgeStatusDeleted = 10 + BadgeStatusInactive = 11 + + BadgeLevelBronze BadgeLevel = 1 + BadgeLevelSilver BadgeLevel = 2 + BadgeLevelGold BadgeLevel = 3 + + BadgeSingleAward = 1 + BadgeMultiAward = 2 +) + +// Badge badge +type Badge struct { + ID string `xorm:"not null pk BIGINT(20) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + Name string `xorm:"not null default '' VARCHAR(256) name"` + Icon string `xorm:"not null default '' VARCHAR(1024) icon"` + AwardCount int `xorm:"not null default 0 INT(11) award_count"` + Description string `xorm:"not null MEDIUMTEXT description"` + Status int8 `xorm:"not null default 1 INT(11) status"` + BadgeGroupID int64 `xorm:"not null default 0 BIGINT(20) badge_group_id"` + Level BadgeLevel `xorm:"not null default 1 TINYINT(4) level"` + Single int8 `xorm:"not null default 1 TINYINT(4) single"` + Collect string `xorm:"not null default '' VARCHAR(128) collect"` + Handler string `xorm:"not null default '' VARCHAR(128) handler"` + Param string `xorm:"not null TEXT param"` +} + +// TableName badge table name +func (b *Badge) TableName() string { + return "badge" +} + +func (b *Badge) GetIntParam(key string) int64 { + return gjson.Get(b.Param, key).Int() +} + +func (b *Badge) GetStringParam(key string) string { + return gjson.Get(b.Param, key).String() +} diff --git a/internal/entity/badge_group_entity.go b/internal/entity/badge_group_entity.go new file mode 100644 index 000000000..3be4d8209 --- /dev/null +++ b/internal/entity/badge_group_entity.go @@ -0,0 +1,35 @@ +/* + * 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 entity + +import "time" + +// BadgeGroup badge_group +type BadgeGroup struct { + ID string `json:"id" xorm:"not null pk autoincr BIGINT(20) id"` + Name string `json:"name" xorm:"not null default '' VARCHAR(256) name"` + CreatedAt time.Time `json:"created_at" xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `json:"updated_at" xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` +} + +// TableName badge_group table name +func (BadgeGroup) TableName() string { + return "badge_group" +} diff --git a/internal/migrations/init.go b/internal/migrations/init.go index a56216aab..b74e36886 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -65,6 +65,7 @@ func (m *Mentor) InitDB() error { m.do("init version table", m.initVersionTable) m.do("init admin user", m.initAdminUser) m.do("init config", m.initConfig) + m.do("init badge", m.initBadge) m.do("init default privileges config", m.initDefaultRankPrivileges) m.do("init role", m.initRole) m.do("init power", m.initPower) @@ -79,6 +80,7 @@ func (m *Mentor) InitDB() error { m.do("init site info privilege rank", m.initSiteInfoPrivilegeRank) m.do("init site info write", m.initSiteInfoWrite) m.do("init default content", m.initDefaultContent) + m.do("init default badges", m.initDefaultBadges) return m.err } @@ -126,6 +128,26 @@ func (m *Mentor) initConfig() { _, m.err = m.engine.Context(m.ctx).Insert(defaultConfigTable) } +// initBadge init badge's table and data +func (m *Mentor) initBadge() { + uniqueIDRepo := unique.NewUniqueIDRepo(&data.Data{DB: m.engine}) + _, m.err = m.engine.Context(m.ctx).Insert(defaultBadgeGroupTable) + if m.err != nil { + return + } + + for _, badge := range defaultBadgeTable { + badge.ID, m.err = uniqueIDRepo.GenUniqueIDStr(m.ctx, new(entity.Badge).TableName()) + if m.err != nil { + return + } + _, m.err = m.engine.Context(m.ctx).Insert(badge) + if m.err != nil { + return + } + } +} + func (m *Mentor) initDefaultRankPrivileges() { chooseOption := schema.DefaultPrivilegeOptions.Choose(schema.PrivilegeLevel2) for _, privilege := range chooseOption.Privileges { @@ -411,3 +433,22 @@ func (m *Mentor) initDefaultContent() { return } } + +func (m *Mentor) initDefaultBadges() { + uniqueIDRepo := unique.NewUniqueIDRepo(&data.Data{DB: m.engine}) + + _, m.err = m.engine.Context(m.ctx).Insert(defaultBadgeGroupTable) + if m.err != nil { + return + } + for _, badge := range defaultBadgeTable { + badge.ID, m.err = uniqueIDRepo.GenUniqueIDStr(m.ctx, new(entity.Badge).TableName()) + if m.err != nil { + return + } + if _, m.err = m.engine.Context(m.ctx).Insert(badge); m.err != nil { + return + } + } + return +} diff --git a/internal/migrations/init_data.go b/internal/migrations/init_data.go index adbe71753..50a5651b6 100644 --- a/internal/migrations/init_data.go +++ b/internal/migrations/init_data.go @@ -69,6 +69,9 @@ var ( &entity.UserNotificationConfig{}, &entity.PluginUserConfig{}, &entity.Review{}, + &entity.Badge{}, + &entity.BadgeGroup{}, + &entity.BadgeAward{}, } roles = []*entity.Role{ @@ -344,4 +347,160 @@ var ( {ID: 129, Key: "rank.question.undeleted", Value: `-1`}, {ID: 130, Key: "rank.tag.undeleted", Value: `-1`}, } + + defaultBadgeGroupTable = []*entity.BadgeGroup{ + {ID: "1", Name: "badge.default_badge_groups.getting_started.name"}, + {ID: "2", Name: "badge.default_badge_groups.community.name"}, + {ID: "3", Name: "badge.default_badge_groups.posting.name"}, + } + + defaultBadgeTable = []*entity.Badge{ + { + Name: "badge.default_badges.autobiographer.name", + Icon: "person-badge-fill", + Description: "badge.default_badges.autobiographer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstUpdateUserProfile", + }, + { + Name: "badge.default_badges.editor.name", + Icon: "pencil-fill", + Description: "badge.default_badges.editor.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstPostEdit", + }, + { + Name: "badge.default_badges.first_flag.name", + Icon: "flag-fill", + Description: "badge.default_badges.first_flag.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstFlaggedPost", + }, + { + Name: "badge.default_badges.first_upvote.name", + Icon: "hand-thumbs-up-fill", + Description: "badge.default_badges.first_upvote.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstVotedPost", + }, + { + Name: "badge.default_badges.first_reaction.name", + Icon: "emoji-smile-fill", + Description: "badge.default_badges.first_reaction.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstReactedPost", + }, + { + Name: "badge.default_badges.first_share.name", + Icon: "share-fill", + Description: "badge.default_badges.first_share.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstSharedPost", + }, + { + Name: "badge.default_badges.scholar.name", + Icon: "check-circle-fill", + Description: "badge.default_badges.scholar.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 1, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "FirstAcceptAnswer", + }, + { + Name: "badge.default_badges.solved.name", + Icon: "check-square-fill", + Description: "badge.default_badges.solved.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 2, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeSingleAward, + Handler: "ReachAnswerAcceptedAmount", + Param: `{"amount":"1"}`, + }, + { + Name: "badge.default_badges.nice_answer.name", + Icon: "chat-square-text-fill", + Description: "badge.default_badges.nice_answer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeMultiAward, + Handler: "ReachAnswerVote", + Param: `{"amount":"10"}`, + }, + { + Name: "badge.default_badges.good_answer.name", + Icon: "chat-square-text-fill", + Description: "badge.default_badges.good_answer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelSilver, + Single: entity.BadgeMultiAward, + Handler: "ReachAnswerVote", + Param: `{"amount":"25"}`, + }, + { + Name: "badge.default_badges.great_answer.name", + Icon: "chat-square-text-fill", + Description: "badge.default_badges.great_answer.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelGold, + Single: entity.BadgeMultiAward, + Handler: "ReachAnswerVote", + Param: `{"amount":"50"}`, + }, + { + Name: "badge.default_badges.nice_question.name", + Icon: "question-circle-fill", + Description: "badge.default_badges.nice_question.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelBronze, + Single: entity.BadgeMultiAward, + Handler: "ReachQuestionVote", + Param: `{"amount":"10"}`, + }, + { + Name: "badge.default_badges.good_question.name", + Icon: "question-circle-fill", + Description: "badge.default_badges.good_question.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelSilver, + Single: entity.BadgeMultiAward, + Handler: "ReachQuestionVote", + Param: `{"amount":"25"}`, + }, + { + Name: "badge.default_badges.great_question.name", + Icon: "question-circle-fill", + Description: "badge.default_badges.great_question.desc", + Status: entity.BadgeStatusActive, + BadgeGroupID: 3, + Level: entity.BadgeLevelGold, + Single: entity.BadgeMultiAward, + Handler: "ReachQuestionVote", + Param: `{"amount":"50"}`, + }, + } ) diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 463f68ed8..a32e851a7 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -97,6 +97,7 @@ var migrations = []Migration{ NewMigration("v1.2.5", "add notification plugin and theme config", addNotificationPluginAndThemeConfig, true), NewMigration("v1.3.0", "add review", addReview, false), NewMigration("v1.3.6", "add hot score to question table", addQuestionHotScore, true), + NewMigration("v1.4.0", "add badge/badge_group/badge_award table", addBadges, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v22.go b/internal/migrations/v22.go new file mode 100644 index 000000000..ab7185e32 --- /dev/null +++ b/internal/migrations/v22.go @@ -0,0 +1,72 @@ +/* + * 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 migrations + +import ( + "context" + "fmt" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/repo/unique" + "xorm.io/xorm" +) + +func addBadges(ctx context.Context, x *xorm.Engine) (err error) { + uniqueIDRepo := unique.NewUniqueIDRepo(&data.Data{DB: x}) + + err = x.Context(ctx).Sync(new(entity.Badge), new(entity.BadgeGroup), new(entity.BadgeAward)) + if err != nil { + return fmt.Errorf("sync table failed: %w", err) + } + + for _, badgeGroup := range defaultBadgeGroupTable { + exist, err := x.Context(ctx).Get(&entity.BadgeGroup{ID: badgeGroup.ID}) + if err != nil { + return err + } + if exist { + _, err = x.Context(ctx).ID(badgeGroup.ID).Update(badgeGroup) + } else { + _, err = x.Context(ctx).Insert(badgeGroup) + } + if err != nil { + return fmt.Errorf("insert badge group failed: %w", err) + } + } + + for _, badge := range defaultBadgeTable { + exist, err := x.Context(ctx).Get(&entity.Badge{Name: badge.Name}) + if err != nil { + return err + } + if exist { + continue + } + badge.ID, err = uniqueIDRepo.GenUniqueIDStr(ctx, new(entity.Badge).TableName()) + if err != nil { + return err + } + + if _, err := x.Context(ctx).Insert(badge); err != nil { + return err + } + } + return +} diff --git a/internal/repo/badge/badge_event_rule.go b/internal/repo/badge/badge_event_rule.go new file mode 100644 index 000000000..f107203e8 --- /dev/null +++ b/internal/repo/badge/badge_event_rule.go @@ -0,0 +1,256 @@ +/* + * 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 badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/constant" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "strconv" +) + +// eventRuleRepo event rule repo +type eventRuleRepo struct { + data *data.Data + EventRuleMapping map[constant.EventType][]badge.EventRuleHandler +} + +// NewEventRuleRepo creates a new badge repository +func NewEventRuleRepo(data *data.Data) badge.EventRuleRepo { + b := &eventRuleRepo{ + data: data, + } + b.EventRuleMapping = map[constant.EventType][]badge.EventRuleHandler{ + constant.EventUserUpdate: {b.FirstUpdateUserProfile}, + constant.EventUserShare: {b.FirstSharedPost}, + constant.EventQuestionCreate: nil, + constant.EventQuestionUpdate: {b.FirstPostEdit}, + constant.EventQuestionDelete: nil, + constant.EventQuestionVote: {b.FirstVotedPost, b.ReachQuestionVote}, + constant.EventQuestionAccept: {b.FirstAcceptAnswer, b.ReachAnswerAcceptedAmount}, + constant.EventQuestionFlag: {b.FirstFlaggedPost}, + constant.EventQuestionReact: {b.FirstReactedPost}, + constant.EventAnswerCreate: nil, + constant.EventAnswerUpdate: {b.FirstPostEdit}, + constant.EventAnswerDelete: nil, + constant.EventAnswerVote: {b.FirstVotedPost, b.ReachAnswerVote}, + constant.EventAnswerFlag: {b.FirstFlaggedPost}, + constant.EventAnswerReact: {b.FirstReactedPost}, + constant.EventCommentCreate: nil, + constant.EventCommentUpdate: nil, + constant.EventCommentDelete: nil, + constant.EventCommentVote: {b.FirstVotedPost}, + constant.EventCommentFlag: {b.FirstFlaggedPost}, + } + return b +} + +// HandleEventWithRule handle event with rule +func (br *eventRuleRepo) HandleEventWithRule(ctx context.Context, msg *schema.EventMsg) ( + awards []*entity.BadgeAward) { + handlers := br.EventRuleMapping[msg.EventType] + for _, h := range handlers { + t, err := h(ctx, msg) + if err != nil { + log.Errorf("error handling badge event %+v: %v", msg, err) + } else { + awards = append(awards, t...) + } + } + return awards +} + +// FirstUpdateUserProfile first update user profile +func (br *eventRuleRepo) FirstUpdateUserProfile(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstUpdateUserProfile") + for _, b := range badges { + bean := &entity.User{ID: event.UserID} + exist, err := br.data.DB.Context(ctx).Get(bean) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if !exist { + continue + } + if len(bean.Bio) > 0 { + awards = append(awards, br.createBadgeAward(event.UserID, "", b)) + } + } + return awards, nil +} + +// FirstPostEdit first post edit +func (br *eventRuleRepo) FirstPostEdit(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstPostEdit") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstFlaggedPost first flagged post. +func (br *eventRuleRepo) FirstFlaggedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstFlaggedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstVotedPost first voted post +func (br *eventRuleRepo) FirstVotedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstVotedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstReactedPost first reacted post +func (br *eventRuleRepo) FirstReactedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstReactedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstSharedPost first shared post +func (br *eventRuleRepo) FirstSharedPost(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstSharedPost") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// FirstAcceptAnswer user first accept answer +func (br *eventRuleRepo) FirstAcceptAnswer(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "FirstAcceptAnswer") + for _, b := range badges { + awards = append(awards, br.createBadgeAward(event.UserID, event.GetObjectID(), b)) + } + return awards, nil +} + +// ReachAnswerAcceptedAmount reach answer accepted amount +func (br *eventRuleRepo) ReachAnswerAcceptedAmount(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "ReachAnswerAcceptedAmount") + if len(event.AnswerUserID) == 0 { + return nil, nil + } + + // count user's accepted answer amount + amount, err := br.data.DB.Context(ctx).Count(&entity.Answer{ + UserID: event.AnswerUserID, + Accepted: schema.AnswerAcceptedEnable, + Status: entity.AnswerStatusAvailable, + }) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + for _, b := range badges { + // get badge requirement + requirement := b.GetIntParam("amount") + if requirement == 0 || amount < requirement { + continue + } + awards = append(awards, br.createBadgeAward(event.AnswerUserID, event.AnswerID, b)) + } + return awards, nil +} + +// ReachAnswerVote reach answer vote +func (br *eventRuleRepo) ReachAnswerVote(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "ReachAnswerVote") + // get vote amount + amount, _ := strconv.Atoi(event.GetExtra("vote_up_amount")) + if amount == 0 { + return nil, nil + } + + for _, b := range badges { + // get badge requirement + requirement := b.GetIntParam("amount") + if requirement == 0 || int64(amount) < requirement { + continue + } + awards = append(awards, br.createBadgeAward(event.AnswerUserID, event.AnswerID, b)) + } + return awards, nil +} + +// ReachQuestionVote reach question vote +func (br *eventRuleRepo) ReachQuestionVote(ctx context.Context, + event *schema.EventMsg) (awards []*entity.BadgeAward, err error) { + badges := br.getBadgesByHandler(ctx, "ReachQuestionVote") + // get vote amount + amount, _ := strconv.Atoi(event.GetExtra("vote_up_amount")) + if amount == 0 { + return nil, nil + } + + for _, b := range badges { + // get badge requirement + requirement := b.GetIntParam("amount") + if requirement == 0 || int64(amount) < requirement { + continue + } + awards = append(awards, br.createBadgeAward(event.QuestionUserID, event.QuestionID, b)) + } + return awards, nil +} + +func (br *eventRuleRepo) getBadgesByHandler(ctx context.Context, handler string) (badges []*entity.Badge) { + badges = make([]*entity.Badge, 0) + err := br.data.DB.Context(ctx).Where("handler = ?", handler).Find(&badges) + if err != nil { + log.Errorf("error getting badge by handler %s: %v", handler, err) + return nil + } + return badges +} + +func (br *eventRuleRepo) createBadgeAward(userID, awardKey string, badge *entity.Badge) (awards *entity.BadgeAward) { + if badge.Single == entity.BadgeSingleAward { + awardKey = entity.BadgeOnceAwardKey + } + return &entity.BadgeAward{ + UserID: userID, + BadgeID: badge.ID, + AwardKey: awardKey, + } +} diff --git a/internal/repo/badge/badge_repo.go b/internal/repo/badge/badge_repo.go new file mode 100644 index 000000000..80689541f --- /dev/null +++ b/internal/repo/badge/badge_repo.go @@ -0,0 +1,142 @@ +/* + * 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 badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/internal/service/unique" + "github.com/segmentfault/pacman/errors" + "xorm.io/xorm" +) + +type badgeRepo struct { + data *data.Data + uniqueIDRepo unique.UniqueIDRepo +} + +// NewBadgeRepo creates a new badge repository +func NewBadgeRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) badge.BadgeRepo { + return &badgeRepo{ + data: data, + uniqueIDRepo: uniqueIDRepo, + } +} + +func (r *badgeRepo) GetByID(ctx context.Context, id string) (badge *entity.Badge, exists bool, err error) { + badge = &entity.Badge{} + exists, err = r.data.DB.Context(ctx).Where("id = ?", id).Get(badge) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (r *badgeRepo) GetByIDs(ctx context.Context, ids []string) (badges []*entity.Badge, err error) { + badges = make([]*entity.Badge, 0) + err = r.data.DB.Context(ctx).In("id", ids).Find(&badges) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListPaged returns a list of activated badges +func (r *badgeRepo) ListPaged(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) { + badges = make([]*entity.Badge, 0) + total = 0 + + session := r.data.DB.Context(ctx).Where("status <> ?", entity.BadgeStatusDeleted) + if page == 0 || pageSize == 0 { + err = session.Find(&badges) + } else { + total, err = pager.Help(page, pageSize, &badges, &entity.Badge{}, session) + } + + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListActivated returns a list of activated badges +func (r *badgeRepo) ListActivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) { + badges = make([]*entity.Badge, 0) + total = 0 + + session := r.data.DB.Context(ctx).Where("status = ?", entity.BadgeStatusActive) + if page == 0 || pageSize == 0 { + err = session.Find(&badges) + } else { + total, err = pager.Help(page, pageSize, &badges, &entity.Badge{}, session) + } + + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListInactivated returns a list of inactivated badges +func (r *badgeRepo) ListInactivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) { + badges = make([]*entity.Badge, 0) + total = 0 + + session := r.data.DB.Context(ctx).Where("status = ?", entity.BadgeStatusInactive) + if page == 0 || pageSize == 0 { + err = session.Find(&badges) + } else { + total, err = pager.Help(page, pageSize, &badges, &entity.Badge{}, session) + } + + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// UpdateStatus updates the award count of a badge +func (r *badgeRepo) UpdateStatus(ctx context.Context, id string, status int8) (err error) { + _, err = r.data.DB.Transaction(func(session *xorm.Session) (result any, err error) { + _, err = session.ID(id).Update(&entity.Badge{ + Status: status, + }) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(session.Rollback()).WithStack() + return + } + if status >= entity.BadgeStatusDeleted { + _, err = session.Where("badge_id = ?", id).Cols("is_badge_deleted").Update(&entity.BadgeAward{ + IsBadgeDeleted: entity.IsBadgeDeleted, + }) + } else { + _, err = session.Where("badge_id = ?", id).Cols("is_badge_deleted").Update(&entity.BadgeAward{ + IsBadgeDeleted: entity.IsBadgeNotDeleted, + }) + } + return + }) + + return +} diff --git a/internal/repo/badge_award/badge_award_repo.go b/internal/repo/badge_award/badge_award_repo.go new file mode 100644 index 000000000..377677d19 --- /dev/null +++ b/internal/repo/badge_award/badge_award_repo.go @@ -0,0 +1,172 @@ +/* + * 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 badge_award + +import ( + "context" + "fmt" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/base/pager" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/internal/service/unique" + "github.com/segmentfault/pacman/errors" + "xorm.io/xorm" +) + +type badgeAwardRepo struct { + data *data.Data + uniqueIDRepo unique.UniqueIDRepo +} + +func NewBadgeAwardRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) badge.BadgeAwardRepo { + return &badgeAwardRepo{ + data: data, + uniqueIDRepo: uniqueIDRepo, + } +} + +// AwardBadgeForUser award badge for user +func (r *badgeAwardRepo) AwardBadgeForUser(ctx context.Context, badgeAward *entity.BadgeAward) (err error) { + badgeAward.ID, err = r.uniqueIDRepo.GenUniqueIDStr(ctx, entity.BadgeAward{}.TableName()) + if err != nil { + return err + } + + _, err = r.data.DB.Transaction(func(session *xorm.Session) (result any, err error) { + session = session.Context(ctx) + + badgeInfo := &entity.Badge{} + exist, err := session.ID(badgeAward.BadgeID).ForUpdate().Get(badgeInfo) + if err != nil { + return nil, err + } + if !exist { + return nil, fmt.Errorf("badge not exist") + } + + old := &entity.BadgeAward{} + exist, err = session.Where("user_id = ? AND badge_id = ? AND award_key = ? AND is_badge_deleted = 0", + badgeAward.UserID, badgeAward.BadgeID, badgeAward.AwardKey).Get(old) + if err != nil { + return nil, err + } + if exist { + return nil, fmt.Errorf("badge already awarded") + } + + _, err = session.Insert(badgeAward) + if err != nil { + return nil, err + } + + return session.ID(badgeInfo.ID).Incr("award_count", 1).Update(&entity.Badge{}) + }) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +// CheckIsAward check this badge is awarded for this user or not +func (r *badgeAwardRepo) CheckIsAward(ctx context.Context, badgeID, userID, awardKey string, singleOrMulti int8) ( + isAward bool, err error) { + if singleOrMulti == entity.BadgeSingleAward { + _, isAward, err = r.GetByUserIdAndBadgeId(ctx, userID, badgeID) + } else { + _, isAward, err = r.GetByUserIdAndBadgeIdAndAwardKey(ctx, userID, badgeID, awardKey) + } + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return isAward, err +} + +func (r *badgeAwardRepo) CountByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) (awardCount int64) { + awardCount, err := r.data.DB.Context(ctx).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&entity.BadgeAward{}) + if err != nil { + return 0 + } + return +} + +func (r *badgeAwardRepo) SumUserEarnedGroupByBadgeID(ctx context.Context, userID string) (earnedCounts []*entity.BadgeEarnedCount, err error) { + err = r.data.DB.Context(ctx).Select("badge_id, count(`id`) AS earned_count").Where("user_id = ?", userID).GroupBy("badge_id").Find(&earnedCounts) + return +} + +// ListPagedByBadgeId list badge awards by badge id +func (r *badgeAwardRepo) ListPagedByBadgeId(ctx context.Context, badgeID string, page int, pageSize int) (badgeAwardList []*entity.BadgeAward, total int64, err error) { + session := r.data.DB.Context(ctx) + session.Where("badge_id = ?", badgeID) + total, err = pager.Help(page, pageSize, &badgeAwardList, &entity.BadgeAward{}, session) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListPagedByBadgeIdAndUserId list badge awards by badge id and user id +func (r *badgeAwardRepo) ListPagedByBadgeIdAndUserId(ctx context.Context, badgeID string, userID string, page int, pageSize int) (badgeAwardList []*entity.BadgeAward, total int64, err error) { + session := r.data.DB.Context(ctx) + session.Where("badge_id = ? AND user_id = ?", badgeID, userID) + total, err = pager.Help(page, pageSize, &badgeAwardList, &entity.Question{}, session) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ListNewestEarned list newest earned badge awards +func (r *badgeAwardRepo) ListNewestEarned(ctx context.Context, userID string, limit int) (badgeAwards []*entity.BadgeAwardRecent, err error) { + badgeAwards = make([]*entity.BadgeAwardRecent, 0) + err = r.data.DB.Context(ctx). + Select("user_id, badge_id, max(created_at) created,count(*) earned_count"). + Where("user_id = ? AND is_badge_deleted = ? ", userID, entity.IsBadgeNotDeleted). + GroupBy("badge_id"). + OrderBy("created desc"). + Limit(limit).Find(&badgeAwards) + return +} + +// GetByUserIdAndBadgeId get badge award by user id and badge id +func (r *badgeAwardRepo) GetByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) ( + badgeAward *entity.BadgeAward, exists bool, err error) { + badgeAward = &entity.BadgeAward{} + exists, err = r.data.DB.Context(ctx). + Where("user_id = ? AND badge_id = ? AND is_badge_deleted = 0", userID, badgeID).Get(badgeAward) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// GetByUserIdAndBadgeIdAndAwardKey get badge award by user id and badge id and award key +func (r *badgeAwardRepo) GetByUserIdAndBadgeIdAndAwardKey(ctx context.Context, userID string, badgeID string, awardKey string) ( + badgeAward *entity.BadgeAward, exists bool, err error) { + badgeAward = &entity.BadgeAward{} + exists, err = r.data.DB.Context(ctx). + Where("user_id = ? AND badge_id = ? AND award_key = ? AND is_badge_deleted = 0", userID, badgeID, awardKey).Get(badgeAward) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/badge_group/badge_group_repo.go b/internal/repo/badge_group/badge_group_repo.go new file mode 100644 index 000000000..63f438b9e --- /dev/null +++ b/internal/repo/badge_group/badge_group_repo.go @@ -0,0 +1,50 @@ +/* + * 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 badge_group + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/service/badge" + "github.com/apache/incubator-answer/internal/service/unique" +) + +type badgeGroupRepo struct { + data *data.Data + uniqueIDRepo unique.UniqueIDRepo +} + +func NewBadgeGroupRepo(data *data.Data, uniqueIDRepo unique.UniqueIDRepo) badge.BadgeGroupRepo { + return &badgeGroupRepo{ + data: data, + uniqueIDRepo: uniqueIDRepo, + } +} + +func (r *badgeGroupRepo) ListGroups(ctx context.Context) (groups []*entity.BadgeGroup, err error) { + groups = make([]*entity.BadgeGroup, 0) + err = r.data.DB.Context(ctx).Find(&groups) + return +} + +func (r *badgeGroupRepo) AddGroup(ctx context.Context, group *entity.BadgeGroup) (err error) { + return +} diff --git a/internal/repo/notification/notification_repo.go b/internal/repo/notification/notification_repo.go index 6b4f0040d..bd325ef27 100644 --- a/internal/repo/notification/notification_repo.go +++ b/internal/repo/notification/notification_repo.go @@ -69,7 +69,7 @@ func (nr *notificationRepo) UpdateNotificationContent(ctx context.Context, notif func (nr *notificationRepo) ClearUnRead(ctx context.Context, userID string, notificationType int) (err error) { info := &entity.Notification{} info.IsRead = schema.NotificationRead - _, err = nr.data.DB.Context(ctx).Where("user_id =?", userID).And("type =?", notificationType).Cols("is_read").Update(info) + _, err = nr.data.DB.Context(ctx).Where("user_id = ?", userID).And("type = ?", notificationType).Cols("is_read").Update(info) if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } @@ -79,7 +79,7 @@ func (nr *notificationRepo) ClearUnRead(ctx context.Context, userID string, noti func (nr *notificationRepo) ClearIDUnRead(ctx context.Context, userID string, id string) (err error) { info := &entity.Notification{} info.IsRead = schema.NotificationRead - _, err = nr.data.DB.Context(ctx).Where("user_id =?", userID).And("id =?", id).Cols("is_read").Update(info) + _, err = nr.data.DB.Context(ctx).Where("user_id = ?", userID).And("id = ?", id).Cols("is_read").Update(info) if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } @@ -98,7 +98,7 @@ func (nr *notificationRepo) GetById(ctx context.Context, id string) (*entity.Not func (nr *notificationRepo) GetByUserIdObjectIdTypeId(ctx context.Context, userID, objectID string, notificationType int) (*entity.Notification, bool, error) { info := &entity.Notification{} - exist, err := nr.data.DB.Context(ctx).Where("user_id = ? ", userID).And("object_id = ?", objectID).And("type = ?", notificationType).Get(info) + exist, err := nr.data.DB.Context(ctx).Where("user_id = ?", userID).And("object_id = ?", objectID).And("type = ?", notificationType).Get(info) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() return info, false, err diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 3a517120e..7f222a425 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -25,6 +25,9 @@ import ( "github.com/apache/incubator-answer/internal/repo/activity_common" "github.com/apache/incubator-answer/internal/repo/answer" "github.com/apache/incubator-answer/internal/repo/auth" + "github.com/apache/incubator-answer/internal/repo/badge" + "github.com/apache/incubator-answer/internal/repo/badge_award" + "github.com/apache/incubator-answer/internal/repo/badge_group" "github.com/apache/incubator-answer/internal/repo/captcha" "github.com/apache/incubator-answer/internal/repo/collection" "github.com/apache/incubator-answer/internal/repo/comment" @@ -100,4 +103,8 @@ var ProviderSetRepo = wire.NewSet( limit.NewRateLimitRepo, plugin_config.NewPluginUserConfigRepo, review.NewReviewRepo, + badge.NewBadgeRepo, + badge.NewEventRuleRepo, + badge_group.NewBadgeGroupRepo, + badge_award.NewBadgeAwardRepo, ) diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 328541868..b4ec3bdcc 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -55,6 +55,8 @@ type AnswerAPIRouter struct { userPluginController *controller.UserPluginController reviewController *controller.ReviewController metaController *controller.MetaController + badgeController *controller.BadgeController + adminBadgeController *controller_admin.BadgeController } func NewAnswerAPIRouter( @@ -86,6 +88,8 @@ func NewAnswerAPIRouter( userPluginController *controller.UserPluginController, reviewController *controller.ReviewController, metaController *controller.MetaController, + badgeController *controller.BadgeController, + adminBadgeController *controller_admin.BadgeController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -116,6 +120,8 @@ func NewAnswerAPIRouter( userPluginController: userPluginController, reviewController: reviewController, metaController: metaController, + badgeController: badgeController, + adminBadgeController: adminBadgeController, } } @@ -187,6 +193,13 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { // reaction r.GET("/meta/reaction", a.metaController.GetReaction) + + // badges + r.GET("/badge", a.badgeController.GetBadgeInfo) + r.GET("/badge/awards/page", a.badgeController.GetBadgeAwardList) + r.GET("/badge/user/awards/recent", a.badgeController.GetRecentBadgeAwardListByUsername) + r.GET("/badge/user/awards", a.badgeController.GetAllBadgeAwardListByUsername) + r.GET("/badges", a.badgeController.GetBadgeList) } func (a *AnswerAPIRouter) RegisterAuthUserWithAnyStatusAnswerAPIRouter(r *gin.RouterGroup) { @@ -359,4 +372,8 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { r.PUT("/plugin/status", a.pluginController.UpdatePluginStatus) r.GET("/plugin/config", a.pluginController.GetPluginConfig) r.PUT("/plugin/config", a.pluginController.UpdatePluginConfig) + + // badge + r.GET("/badges", a.adminBadgeController.GetBadgeList) + r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus) } diff --git a/internal/schema/badge_schema.go b/internal/schema/badge_schema.go new file mode 100644 index 000000000..efbcd37ad --- /dev/null +++ b/internal/schema/badge_schema.go @@ -0,0 +1,191 @@ +/* + * 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 schema + +import "github.com/apache/incubator-answer/internal/entity" + +const ( + BadgeStatusActive BadgeStatus = "active" + BadgeStatusInactive BadgeStatus = "inactive" +) + +type BadgeStatus string + +var BadgeStatusMap = map[int8]BadgeStatus{ + entity.BadgeStatusActive: BadgeStatusActive, + entity.BadgeStatusInactive: BadgeStatusInactive, +} + +var BadgeStatusEMap = map[BadgeStatus]int8{ + BadgeStatusActive: entity.BadgeStatusActive, + BadgeStatusInactive: entity.BadgeStatusInactive, +} + +// BadgeListInfo get badge list response +type BadgeListInfo struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge earned count + Earned bool `json:"earned" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} + +type GetBadgeListResp struct { + // badge list info + Badges []*BadgeListInfo `json:"badges" ` + // badge group name + GroupName string `json:"group_name" ` +} + +type UpdateBadgeStatusReq struct { + // badge id + ID string `validate:"required" json:"id"` + // badge status + Status BadgeStatus `validate:"required" json:"status"` +} + +type GetBadgeListPagedReq struct { + // page + Page int `validate:"omitempty,min=1" form:"page"` + // page size + PageSize int `validate:"omitempty,min=1" form:"page_size"` + // badge status + Status BadgeStatus `validate:"omitempty" form:"status"` + // query condition + Query string `validate:"omitempty" form:"q"` +} + +type GetBadgeListPagedResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge description + Description string `json:"description" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge earned count + Earned bool `json:"earned" ` + // badge level + Level entity.BadgeLevel `json:"level" ` + // badge group name + GroupName string `json:"group_name" ` + // badge status + Status BadgeStatus `json:"status"` +} + +type GetBadgeInfoResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge description + Description string `json:"description" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge earned count + EarnedCount int64 `json:"earned_count" ` + // badge is single or multiple + IsSingle bool `json:"is_single" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} + +type GetBadgeAwardWithPageReq struct { + // page + Page int `validate:"omitempty,min=1" form:"page"` + // page size + PageSize int `validate:"omitempty,min=1" form:"page_size"` + // badge id + BadgeID string `validate:"required" form:"badge_id"` + // username + Username string `validate:"omitempty,gt=0,lte=100" form:"username"` + // user id + UserID string `json:"-"` +} + +type GetBadgeAwardWithPageResp struct { + // created time + CreatedAt int64 `json:"created_at"` + // object id + ObjectID string `json:"object_id"` + // question id + QuestionID string `json:"question_id"` + // answer id + AnswerID string `json:"answer_id"` + // comment id + CommentID string `json:"comment_id"` + // object type + ObjectType string `json:"object_type" enums:"question,answer,comment"` + // url title + UrlTitle string `json:"url_title"` + // author user info + AuthorUserInfo UserBasicInfo `json:"author_user_info"` +} + +type GetUserBadgeAwardListReq struct { + // username + Username string `validate:"required,gt=0,lte=100" form:"username"` + // user id + UserID string `json:"-"` + Limit int `json:"-"` +} + +type GetUserBadgeAwardListResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + EarnedCount int64 `json:"earned_count" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} + +// GetBadgeByIDResp get badge by id response +type GetBadgeByIDResp struct { + // badge id + ID string `json:"id" ` + // badge name + Name string `json:"name" ` + // badge description + Description string `json:"description" ` + // badge icon + Icon string `json:"icon" ` + // badge award count + AwardCount int `json:"award_count" ` + // badge is single or multiple + IsSingle bool `json:"is_single" ` + // badge level + Level entity.BadgeLevel `json:"level" ` +} diff --git a/internal/schema/event_schema.go b/internal/schema/event_schema.go new file mode 100644 index 000000000..a507f2699 --- /dev/null +++ b/internal/schema/event_schema.go @@ -0,0 +1,94 @@ +/* + * 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 schema + +import "github.com/apache/incubator-answer/internal/base/constant" + +// EventMsg event message +type EventMsg struct { + EventType constant.EventType + UserID string + + QuestionID string + QuestionUserID string + + AnswerID string + AnswerUserID string + + CommentID string + CommentUserID string + + ExtraInfo map[string]string +} + +// NewEvent create a new event +func NewEvent(e constant.EventType, userID string) *EventMsg { + return &EventMsg{ + UserID: userID, + EventType: e, + ExtraInfo: make(map[string]string), + } +} + +// QID get question id +func (e *EventMsg) QID(questionID, userID string) *EventMsg { + e.QuestionID = questionID + e.QuestionUserID = userID + return e +} + +// AID get answer id +func (e *EventMsg) AID(answerID, userID string) *EventMsg { + e.AnswerID = answerID + e.AnswerUserID = userID + return e +} + +// CID get comment id +func (e *EventMsg) CID(comment, userID string) *EventMsg { + e.CommentID = comment + e.CommentUserID = userID + return e +} + +// AddExtra add extra info +func (e *EventMsg) AddExtra(key, value string) *EventMsg { + e.ExtraInfo[key] = value + return e +} + +// GetExtra get extra info +func (e *EventMsg) GetExtra(key string) string { + if v, ok := e.ExtraInfo[key]; ok { + return v + } + return "" +} + +// GetObjectID get object id +func (e *EventMsg) GetObjectID() string { + if len(e.CommentID) > 0 { + return e.CommentID + } + if len(e.AnswerID) > 0 { + return e.AnswerID + } + return e.QuestionID +} diff --git a/internal/schema/notification_schema.go b/internal/schema/notification_schema.go index 4e0e93169..8d4b694e9 100644 --- a/internal/schema/notification_schema.go +++ b/internal/schema/notification_schema.go @@ -19,6 +19,12 @@ package schema +import ( + "encoding/json" + "github.com/apache/incubator-answer/internal/entity" + "sort" +) + const ( NotificationTypeInbox = 1 NotificationTypeAchievement = 2 @@ -95,10 +101,70 @@ type ObjectInfo struct { } type RedDot struct { - Inbox int64 `json:"inbox"` - Achievement int64 `json:"achievement"` - Revision int64 `json:"revision"` - CanRevision bool `json:"can_revision"` + Inbox int64 `json:"inbox"` + Achievement int64 `json:"achievement"` + Revision int64 `json:"revision"` + CanRevision bool `json:"can_revision"` + BadgeAward *RedDotBadgeAward `json:"badge_award"` +} + +type RedDotBadgeAward struct { + NotificationID string `json:"notification_id"` + BadgeID string `json:"badge_id"` + Name string `json:"name"` + Icon string `json:"icon"` + Level entity.BadgeLevel `json:"level"` +} + +type RedDotBadgeAwardCache struct { + BadgeAwardList map[string]*RedDotBadgeAward `json:"badge_award_list"` +} + +// NewRedDotBadgeAwardCache new red dot badge award cache +func NewRedDotBadgeAwardCache() *RedDotBadgeAwardCache { + return &RedDotBadgeAwardCache{ + BadgeAwardList: make(map[string]*RedDotBadgeAward), + } +} + +// GetBadgeAward get badge award +func (r *RedDotBadgeAwardCache) GetBadgeAward() *RedDotBadgeAward { + if len(r.BadgeAwardList) == 0 { + return nil + } + var ids []string + for _, v := range r.BadgeAwardList { + ids = append(ids, v.NotificationID) + } + sort.Strings(ids) + return r.BadgeAwardList[ids[0]] +} + +// FromJSON from json +func (r *RedDotBadgeAwardCache) FromJSON(data string) { + _ = json.Unmarshal([]byte(data), r) +} + +// ToJSON to json +func (r *RedDotBadgeAwardCache) ToJSON() string { + data, _ := json.Marshal(r) + return string(data) +} + +// AddBadgeAward add badge award +func (r *RedDotBadgeAwardCache) AddBadgeAward(badgeAward *RedDotBadgeAward) { + if r.BadgeAwardList == nil { + r.BadgeAwardList = make(map[string]*RedDotBadgeAward) + } + r.BadgeAwardList[badgeAward.NotificationID] = badgeAward +} + +// RemoveBadgeAward remove badge award +func (r *RedDotBadgeAwardCache) RemoveBadgeAward(notificationID string) { + if r.BadgeAwardList == nil { + return + } + delete(r.BadgeAwardList, notificationID) } type NotificationSearch struct { @@ -112,8 +178,8 @@ type NotificationSearch struct { } type NotificationClearRequest struct { + NotificationType string `validate:"required,oneof=inbox achievement" json:"type"` UserID string `json:"-"` - TypeStr string `json:"type" form:"type"` // inbox achievement CanReviewQuestion bool `json:"-"` CanReviewAnswer bool `json:"-"` CanReviewTag bool `json:"-"` diff --git a/internal/service/badge/badge_award_service.go b/internal/service/badge/badge_award_service.go new file mode 100644 index 000000000..02cdcf1c8 --- /dev/null +++ b/internal/service/badge/badge_award_service.go @@ -0,0 +1,305 @@ +/* + * 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 badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/constant" + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/base/translator" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/notice_queue" + "github.com/apache/incubator-answer/internal/service/object_info" + usercommon "github.com/apache/incubator-answer/internal/service/user_common" + "github.com/apache/incubator-answer/pkg/uid" + "github.com/gin-gonic/gin" + "github.com/jinzhu/copier" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +type BadgeAwardRepo interface { + CheckIsAward(ctx context.Context, badgeID string, userID string, awardKey string, singleOrMulti int8) (isAward bool, err error) + AwardBadgeForUser(ctx context.Context, badgeAward *entity.BadgeAward) (err error) + + CountByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) (awardCount int64) + + SumUserEarnedGroupByBadgeID(ctx context.Context, userID string) (earnedCounts []*entity.BadgeEarnedCount, err error) + + ListPagedByBadgeId(ctx context.Context, badgeID string, page int, pageSize int) (badgeAwardList []*entity.BadgeAward, total int64, err error) + ListPagedByBadgeIdAndUserId(ctx context.Context, badgeID string, userID string, page int, pageSize int) (badgeAwards []*entity.BadgeAward, total int64, err error) + ListNewestEarned(ctx context.Context, userID string, limit int) (badgeAwards []*entity.BadgeAwardRecent, err error) + + GetByUserIdAndBadgeId(ctx context.Context, userID string, badgeID string) (badgeAward *entity.BadgeAward, exists bool, err error) + GetByUserIdAndBadgeIdAndAwardKey(ctx context.Context, userID string, badgeID string, awardKey string) (badgeAward *entity.BadgeAward, exists bool, err error) +} + +type BadgeAwardService struct { + badgeAwardRepo BadgeAwardRepo + badgeRepo BadgeRepo + userCommon *usercommon.UserCommon + objectInfoService *object_info.ObjService + notificationQueueService notice_queue.NotificationQueueService +} + +func NewBadgeAwardService( + badgeAwardRepo BadgeAwardRepo, + badgeRepo BadgeRepo, + userCommon *usercommon.UserCommon, + objectInfoService *object_info.ObjService, + notificationQueueService notice_queue.NotificationQueueService, +) *BadgeAwardService { + return &BadgeAwardService{ + badgeAwardRepo: badgeAwardRepo, + badgeRepo: badgeRepo, + userCommon: userCommon, + objectInfoService: objectInfoService, + notificationQueueService: notificationQueueService, + } +} + +// GetBadgeAwardList get badge award list +func (bs *BadgeAwardService) GetBadgeAwardList( + ctx context.Context, + req *schema.GetBadgeAwardWithPageReq, +) (resp []*schema.GetBadgeAwardWithPageResp, total int64, err error) { + var ( + badgeAwardList []*entity.BadgeAward + ) + + req.UserID, err = bs.validateUserByUsername(ctx, req.Username) + if err != nil { + badgeAwardList, total, err = bs.badgeAwardRepo.ListPagedByBadgeId(ctx, req.BadgeID, req.Page, req.PageSize) + } else { + badgeAwardList, total, err = bs.badgeAwardRepo.ListPagedByBadgeIdAndUserId(ctx, req.BadgeID, req.UserID, req.Page, req.PageSize) + } + + if err != nil { + return + } + + resp = make([]*schema.GetBadgeAwardWithPageResp, len(badgeAwardList)) + + for i, badgeAward := range badgeAwardList { + var ( + objectID, questionID, answerID, commentID, objectType, urlTitle string + ) + + // if exist object info + objInfo, e := bs.objectInfoService.GetInfo(ctx, badgeAward.AwardKey) + if e == nil && !objInfo.IsDeleted() { + objectID = objInfo.ObjectID + questionID = objInfo.QuestionID + answerID = objInfo.AnswerID + commentID = objInfo.CommentID + objectType = objInfo.ObjectType + urlTitle = objInfo.Title + } + + row := &schema.GetBadgeAwardWithPageResp{ + CreatedAt: badgeAward.CreatedAt.Unix(), + ObjectID: objectID, + QuestionID: questionID, + AnswerID: answerID, + CommentID: commentID, + ObjectType: objectType, + UrlTitle: urlTitle, + AuthorUserInfo: schema.UserBasicInfo{}, + } + + // get user info + userInfo, exists, e := bs.userCommon.GetUserBasicInfoByID(ctx, badgeAward.UserID) + if e != nil { + log.Errorf("user not found by id: %s, err: %v", badgeAward.UserID, e) + } + if exists { + _ = copier.Copy(&row.AuthorUserInfo, userInfo) + } + + resp[i] = row + } + + return +} + +// Award award badge +func (bs *BadgeAwardService) Award(ctx context.Context, badgeID string, userID string, awardKey string) (err error) { + badgeData, exists, err := bs.badgeRepo.GetByID(ctx, badgeID) + if err != nil { + return err + } + + if !exists || badgeData.Status == entity.BadgeStatusInactive { + return errors.BadRequest(reason.BadgeObjectNotFound) + } + + alreadyAwarded, err := bs.badgeAwardRepo.CheckIsAward(ctx, badgeID, userID, awardKey, badgeData.Single) + if err != nil { + return err + } + if alreadyAwarded { + return nil + } + + badgeAward := &entity.BadgeAward{ + UserID: userID, + BadgeID: badgeID, + AwardKey: awardKey, + BadgeGroupID: badgeData.BadgeGroupID, + IsBadgeDeleted: entity.IsBadgeNotDeleted, + } + err = bs.badgeAwardRepo.AwardBadgeForUser(ctx, badgeAward) + if err != nil { + return err + } + + msg := &schema.NotificationMsg{ + TriggerUserID: badgeAward.UserID, + ReceiverUserID: badgeAward.UserID, + Type: schema.NotificationTypeAchievement, + ObjectID: badgeAward.ID, + ObjectType: constant.BadgeAwardObjectType, + Title: badgeData.Name, + ExtraInfo: map[string]string{"badge_id": badgeData.ID}, + NotificationAction: constant.NotificationEarnedBadge, + } + bs.notificationQueueService.Send(ctx, msg) + return nil +} + +// GetUserBadgeAwardList get user badge award list +func (bs *BadgeAwardService) GetUserBadgeAwardList( + ctx *gin.Context, + req *schema.GetUserBadgeAwardListReq, +) ( + resp []*schema.GetUserBadgeAwardListResp, + total int64, + err error, +) { + var ( + earnedCounts []*entity.BadgeEarnedCount + ) + + req.UserID, err = bs.validateUserByUsername(ctx, req.Username) + if err != nil { + return + } + + earnedCounts, err = bs.badgeAwardRepo.SumUserEarnedGroupByBadgeID(ctx, req.UserID) + if err != nil { + return + } + total = int64(len(earnedCounts)) + resp = make([]*schema.GetUserBadgeAwardListResp, total) + + for i, earnedCount := range earnedCounts { + badge, exists, e := bs.badgeRepo.GetByID(ctx, earnedCount.BadgeID) + if e != nil { + err = e + return + } + if !exists { + continue + } + resp[i] = &schema.GetUserBadgeAwardListResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Icon: badge.Icon, + EarnedCount: earnedCount.EarnedCount, + Level: badge.Level, + } + } + + return +} + +// GetUserRecentBadgeAwardList get user badge award list +func (bs *BadgeAwardService) GetUserRecentBadgeAwardList(ctx *gin.Context, req *schema.GetUserBadgeAwardListReq) ( + resp []*schema.GetUserBadgeAwardListResp, total int64, err error) { + var ( + earnedCounts []*entity.BadgeAwardRecent + ) + + req.UserID, err = bs.validateUserByUsername(ctx, req.Username) + if err != nil { + return + } + + earnedCounts, err = bs.badgeAwardRepo.ListNewestEarned(ctx, req.UserID, req.Limit) + if err != nil { + return + } + + total = int64(len(earnedCounts)) + resp = make([]*schema.GetUserBadgeAwardListResp, total) + + for i, earnedCount := range earnedCounts { + badge, exists, e := bs.badgeRepo.GetByID(ctx, earnedCount.BadgeID) + if e != nil { + err = e + return + } + if !exists { + continue + } + resp[i] = &schema.GetUserBadgeAwardListResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Icon: badge.Icon, + EarnedCount: earnedCount.EarnedCount, + Level: badge.Level, + } + } + + return +} + +// validate user + +type userReq struct { + UserID string + Username string +} + +func (bs *BadgeAwardService) validateUserByUsername(ctx context.Context, userName string) (userID string, err error) { + var ( + userInfo *schema.UserBasicInfo + exist bool + ) + // validate user exists or not + if len(userName) > 0 { + userInfo, exist, err = bs.userCommon.GetUserBasicInfoByUserName(ctx, userName) + if err != nil { + return + } + if !exist { + err = errors.BadRequest(reason.UserNotFound) + return + } + userID = userInfo.ID + } + if len(userID) == 0 { + err = errors.BadRequest(reason.UserNotFound) + return + } + return +} diff --git a/internal/service/badge/badge_event_handler.go b/internal/service/badge/badge_event_handler.go new file mode 100644 index 000000000..8a92f08b6 --- /dev/null +++ b/internal/service/badge/badge_event_handler.go @@ -0,0 +1,77 @@ +/* + * 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 badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/data" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/event_queue" + "github.com/segmentfault/pacman/log" +) + +type BadgeEventService struct { + data *data.Data + eventQueueService event_queue.EventQueueService + badgeAwardRepo BadgeAwardRepo + badgeRepo BadgeRepo + eventRuleRepo EventRuleRepo + badgeAwardService *BadgeAwardService +} + +type EventRuleHandler func(ctx context.Context, event *schema.EventMsg) (awards []*entity.BadgeAward, err error) + +type EventRuleRepo interface { + HandleEventWithRule(ctx context.Context, msg *schema.EventMsg) (awards []*entity.BadgeAward) +} + +func NewBadgeEventService( + data *data.Data, + eventQueueService event_queue.EventQueueService, + badgeRepo BadgeRepo, + eventRuleRepo EventRuleRepo, + badgeAwardService *BadgeAwardService, +) *BadgeEventService { + n := &BadgeEventService{ + data: data, + eventQueueService: eventQueueService, + badgeRepo: badgeRepo, + eventRuleRepo: eventRuleRepo, + badgeAwardService: badgeAwardService, + } + eventQueueService.RegisterHandler(n.Handler) + return n +} + +func (ns *BadgeEventService) Handler(ctx context.Context, msg *schema.EventMsg) error { + awards := ns.eventRuleRepo.HandleEventWithRule(ctx, msg) + if len(awards) == 0 { + return nil + } + + for _, award := range awards { + err := ns.badgeAwardService.Award(ctx, award.BadgeID, award.UserID, award.AwardKey) + if err != nil { + log.Debugf("error awarding badge %s: %v", award.BadgeID, err) + } + } + return nil +} diff --git a/internal/service/badge/badge_group_service.go b/internal/service/badge/badge_group_service.go new file mode 100644 index 000000000..16dd74ee4 --- /dev/null +++ b/internal/service/badge/badge_group_service.go @@ -0,0 +1,40 @@ +/* + * 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 badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/entity" +) + +type BadgeGroupRepo interface { + ListGroups(ctx context.Context) (groups []*entity.BadgeGroup, err error) + AddGroup(ctx context.Context, group *entity.BadgeGroup) (err error) +} + +type BadgeGroupService struct { + badgeGroupRepo BadgeGroupRepo +} + +func NewBadgeGroupService(badgeGroupRepo BadgeGroupRepo) *BadgeGroupService { + return &BadgeGroupService{ + badgeGroupRepo: badgeGroupRepo, + } +} diff --git a/internal/service/badge/badge_service.go b/internal/service/badge/badge_service.go new file mode 100644 index 000000000..031f0f152 --- /dev/null +++ b/internal/service/badge/badge_service.go @@ -0,0 +1,297 @@ +/* + * 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 badge + +import ( + "context" + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/reason" + "github.com/apache/incubator-answer/internal/base/translator" + "github.com/apache/incubator-answer/internal/entity" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/pkg/converter" + "github.com/apache/incubator-answer/pkg/uid" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" + "strings" +) + +type BadgeRepo interface { + GetByID(ctx context.Context, id string) (badge *entity.Badge, exists bool, err error) + GetByIDs(ctx context.Context, ids []string) (badges []*entity.Badge, err error) + + ListPaged(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) + ListActivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) + ListInactivated(ctx context.Context, page int, pageSize int) (badges []*entity.Badge, total int64, err error) + + UpdateStatus(ctx context.Context, id string, status int8) (err error) +} + +type BadgeService struct { + badgeRepo BadgeRepo + badgeGroupRepo BadgeGroupRepo + badgeAwardRepo BadgeAwardRepo + badgeEventService *BadgeEventService +} + +func NewBadgeService( + badgeRepo BadgeRepo, + badgeGroupRepo BadgeGroupRepo, + badgeAwardRepo BadgeAwardRepo, + badgeEventService *BadgeEventService, +) *BadgeService { + return &BadgeService{ + badgeRepo: badgeRepo, + badgeGroupRepo: badgeGroupRepo, + badgeAwardRepo: badgeAwardRepo, + badgeEventService: badgeEventService, + } +} + +// ListByGroup list all badges group by group +func (b *BadgeService) ListByGroup(ctx context.Context, userID string) (resp []*schema.GetBadgeListResp, err error) { + var ( + groups []*entity.BadgeGroup + badges []*entity.Badge + earnedCounts []*entity.BadgeEarnedCount + + groupMap = make(map[int64]string, 0) + badgesMap = make(map[int64][]*schema.BadgeListInfo, 0) + ) + resp = make([]*schema.GetBadgeListResp, 0) + + groups, err = b.badgeGroupRepo.ListGroups(ctx) + if err != nil { + return + } + badges, _, err = b.badgeRepo.ListActivated(ctx, 0, 0) + if err != nil { + return + } + + if len(userID) > 0 { + earnedCounts, err = b.badgeAwardRepo.SumUserEarnedGroupByBadgeID(ctx, userID) + if err != nil { + return + } + } + + for _, group := range groups { + groupMap[converter.StringToInt64(group.ID)] = translator.Tr(handler.GetLangByCtx(ctx), group.Name) + } + + for _, badge := range badges { + // check is earned + earned := false + if len(earnedCounts) > 0 { + for _, earnedCount := range earnedCounts { + if badge.ID == earnedCount.BadgeID && earnedCount.EarnedCount > 0 { + earned = true + break + } + } + } + + badgesMap[badge.BadgeGroupID] = append(badgesMap[badge.BadgeGroupID], &schema.BadgeListInfo{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Icon: badge.Icon, + AwardCount: badge.AwardCount, + Earned: earned, + Level: badge.Level, + }) + } + + for _, group := range groups { + resp = append(resp, &schema.GetBadgeListResp{ + GroupName: translator.Tr(handler.GetLangByCtx(ctx), group.Name), + Badges: badgesMap[converter.StringToInt64(group.ID)], + }) + } + + return +} + +// ListPaged list all badges by page +func (b *BadgeService) ListPaged(ctx context.Context, req *schema.GetBadgeListPagedReq) (resp []*schema.GetBadgeListPagedResp, total int64, err error) { + var ( + groups []*entity.BadgeGroup + badges []*entity.Badge + badge *entity.Badge + exists bool + groupMap = make(map[int64]string, 0) + ) + + total = 0 + + if len(req.Query) > 0 { + isID := strings.Index(req.Query, "badge:") + if isID != 0 { + badges, err = b.searchByName(ctx, req.Query) + if err != nil { + return + } + // paged result + count := len(badges) + total = int64(count) + start := (req.Page - 1) * req.PageSize + end := req.Page * req.PageSize + if start >= count { + start = count + end = count + } + if end > count { + end = count + } + badges = badges[start:end] + } else { + req.Query = strings.TrimSpace(strings.TrimLeft(req.Query, "badge:")) + id := uid.DeShortID(req.Query) + if len(id) == 0 { + return + } + badge, exists, err = b.badgeRepo.GetByID(ctx, id) + if err != nil || !exists { + return + } + badges = append(badges, badge) + } + } else { + switch req.Status { + case schema.BadgeStatusActive: + badges, total, err = b.badgeRepo.ListActivated(ctx, req.Page, req.PageSize) + case schema.BadgeStatusInactive: + badges, total, err = b.badgeRepo.ListInactivated(ctx, req.Page, req.PageSize) + default: + badges, total, err = b.badgeRepo.ListPaged(ctx, req.Page, req.PageSize) + } + if err != nil { + return + } + } + + // find all group and build group map + groups, err = b.badgeGroupRepo.ListGroups(ctx) + if err != nil { + return + } + for _, group := range groups { + groupMap[converter.StringToInt64(group.ID)] = translator.Tr(handler.GetLangByCtx(ctx), group.Name) + } + + resp = make([]*schema.GetBadgeListPagedResp, len(badges)) + + for i, badge := range badges { + resp[i] = &schema.GetBadgeListPagedResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Description: translator.Tr(handler.GetLangByCtx(ctx), badge.Description), + Icon: badge.Icon, + AwardCount: badge.AwardCount, + Level: badge.Level, + GroupName: groupMap[badge.BadgeGroupID], + Status: schema.BadgeStatusMap[badge.Status], + } + } + return +} + +// searchByName +func (b *BadgeService) searchByName(ctx context.Context, name string) (result []*entity.Badge, err error) { + var badges []*entity.Badge + name = strings.ToLower(name) + result = make([]*entity.Badge, 0) + + badges, _, err = b.badgeRepo.ListPaged(ctx, 0, 0) + for _, badge := range badges { + tn := strings.ToLower(translator.Tr(handler.GetLangByCtx(ctx), badge.Name)) + if strings.Contains(tn, name) { + result = append(result, badge) + } + } + return +} + +// GetBadgeInfo get badge info +func (b *BadgeService) GetBadgeInfo(ctx *gin.Context, id string, userID string) (info *schema.GetBadgeInfoResp, err error) { + var ( + badge *entity.Badge + earnedTotal int64 = 0 + exists = false + ) + + badge, exists, err = b.badgeRepo.GetByID(ctx, id) + if err != nil { + return + } + + if !exists || badge.Status == entity.BadgeStatusInactive { + err = errors.BadRequest(reason.BadgeObjectNotFound) + return + } + + if len(userID) > 0 { + earnedTotal = b.badgeAwardRepo.CountByUserIdAndBadgeId(ctx, userID, badge.ID) + } + + info = &schema.GetBadgeInfoResp{ + ID: uid.EnShortID(badge.ID), + Name: translator.Tr(handler.GetLangByCtx(ctx), badge.Name), + Description: translator.Tr(handler.GetLangByCtx(ctx), badge.Description), + Icon: badge.Icon, + AwardCount: badge.AwardCount, + EarnedCount: earnedTotal, + IsSingle: badge.Single == entity.BadgeSingleAward, + Level: badge.Level, + } + return +} + +// UpdateStatus update badge status +func (b *BadgeService) UpdateStatus(ctx *gin.Context, req *schema.UpdateBadgeStatusReq) (err error) { + var ( + badge *entity.Badge + exists bool + ) + req.ID = uid.DeShortID(req.ID) + + badge, exists, err = b.badgeRepo.GetByID(ctx, req.ID) + if err != nil { + return + } + if !exists { + err = errors.BadRequest(reason.BadgeObjectNotFound) + return + } + + status, ok := schema.BadgeStatusEMap[req.Status] + // check duplicate action + if badge.Status == status { + return + } + + if !ok { + err = errors.BadRequest(reason.StatusInvalid) + return + } + + err = b.badgeRepo.UpdateStatus(ctx, req.ID, status) + return +} diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index 9f2c45f1a..75d30f9da 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -21,6 +21,7 @@ package comment import ( "context" + "github.com/apache/incubator-answer/internal/service/event_queue" "time" "github.com/apache/incubator-answer/internal/base/constant" @@ -86,6 +87,7 @@ type CommentService struct { notificationQueueService notice_queue.NotificationQueueService externalNotificationQueueService notice_queue.ExternalNotificationQueueService activityQueueService activity_queue.ActivityQueueService + eventQueueService event_queue.EventQueueService } // NewCommentService new comment service @@ -100,6 +102,7 @@ func NewCommentService( notificationQueueService notice_queue.NotificationQueueService, externalNotificationQueueService notice_queue.ExternalNotificationQueueService, activityQueueService activity_queue.ActivityQueueService, + eventQueueService event_queue.EventQueueService, ) *CommentService { return &CommentService{ commentRepo: commentRepo, @@ -112,6 +115,7 @@ func NewCommentService( notificationQueueService: notificationQueueService, externalNotificationQueueService: externalNotificationQueueService, activityQueueService: activityQueueService, + eventQueueService: eventQueueService, } } @@ -184,13 +188,19 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment OriginalObjectID: req.ObjectID, ActivityTypeKey: constant.ActQuestionCommented, } + var event *schema.EventMsg switch objInfo.ObjectType { case constant.QuestionObjectType: activityMsg.ActivityTypeKey = constant.ActQuestionCommented + event = schema.NewEvent(constant.EventCommentCreate, req.UserID). + CID(comment.ID, comment.UserID).QID(objInfo.QuestionID, objInfo.ObjectCreatorUserID) case constant.AnswerObjectType: activityMsg.ActivityTypeKey = constant.ActAnswerCommented + event = schema.NewEvent(constant.EventCommentCreate, req.UserID). + CID(comment.ID, comment.UserID).AID(objInfo.AnswerID, objInfo.ObjectCreatorUserID) } cs.activityQueueService.Send(ctx, activityMsg) + cs.eventQueueService.Send(ctx, event) return resp, nil } @@ -241,7 +251,12 @@ func (cs *CommentService) addCommentNotification( // RemoveComment delete comment func (cs *CommentService) RemoveComment(ctx context.Context, req *schema.RemoveCommentReq) (err error) { - return cs.commentRepo.RemoveComment(ctx, req.CommentID) + err = cs.commentRepo.RemoveComment(ctx, req.CommentID) + if err != nil { + return err + } + cs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventCommentDelete, req.UserID).CID(req.CommentID, req.UserID)) + return nil } // UpdateComment update comment @@ -273,6 +288,8 @@ func (cs *CommentService) UpdateComment(ctx context.Context, req *schema.UpdateC OriginalText: req.OriginalText, ParsedText: req.ParsedText, } + cs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventCommentUpdate, req.UserID). + CID(old.ID, old.UserID)) return resp, nil } diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index f8feda8af..496f7546b 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -22,6 +22,7 @@ package content import ( "context" "encoding/json" + "github.com/apache/incubator-answer/internal/service/event_queue" "time" "github.com/apache/incubator-answer/internal/base/constant" @@ -67,6 +68,7 @@ type AnswerService struct { externalNotificationQueueService notice_queue.ExternalNotificationQueueService activityQueueService activity_queue.ActivityQueueService reviewService *review.ReviewService + eventQueueService event_queue.EventQueueService } func NewAnswerService( @@ -86,6 +88,7 @@ func NewAnswerService( externalNotificationQueueService notice_queue.ExternalNotificationQueueService, activityQueueService activity_queue.ActivityQueueService, reviewService *review.ReviewService, + eventQueueService event_queue.EventQueueService, ) *AnswerService { return &AnswerService{ answerRepo: answerRepo, @@ -104,6 +107,7 @@ func NewAnswerService( externalNotificationQueueService: externalNotificationQueueService, activityQueueService: activityQueueService, reviewService: reviewService, + eventQueueService: eventQueueService, } } @@ -175,6 +179,8 @@ func (as *AnswerService) RemoveAnswer(ctx context.Context, req *schema.RemoveAns OriginalObjectID: answerInfo.ID, ActivityTypeKey: constant.ActAnswerDeleted, }) + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventAnswerDelete, req.UserID). + AID(answerInfo.ID, answerInfo.UserID)) return } @@ -295,6 +301,8 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) ( OriginalObjectID: questionInfo.ID, ActivityTypeKey: constant.ActQuestionAnswered, }) + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventAnswerCreate, req.UserID). + AID(insertData.ID, insertData.UserID)) return insertData.ID, nil } @@ -383,6 +391,8 @@ func (as *AnswerService) Update(ctx context.Context, req *schema.AnswerUpdateReq ActivityTypeKey: constant.ActAnswerEdited, RevisionID: revisionID, }) + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventAnswerUpdate, req.UserID). + AID(insertData.ID, insertData.UserID)) } return insertData.ID, nil @@ -436,6 +446,11 @@ func (as *AnswerService) AcceptAnswer(ctx context.Context, req *schema.AcceptAns oldAnswerInfo.ID = uid.DeShortID(oldAnswerInfo.ID) } + if acceptedAnswerInfo != nil { + as.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionAccept, req.UserID). + QID(questionInfo.ID, questionInfo.UserID).AID(acceptedAnswerInfo.ID, acceptedAnswerInfo.UserID)) + } + as.updateAnswerRank(ctx, req.UserID, questionInfo, acceptedAnswerInfo, oldAnswerInfo) return nil } diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index 6d8b37ba9..dfb778ea1 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -23,6 +23,7 @@ import ( "encoding/json" "fmt" answercommon "github.com/apache/incubator-answer/internal/service/answer_common" + "github.com/apache/incubator-answer/internal/service/event_queue" "strings" "time" @@ -84,6 +85,7 @@ type QuestionService struct { newQuestionNotificationService *notification.ExternalNotificationService reviewService *review.ReviewService configService *config.ConfigService + eventQueueService event_queue.EventQueueService } func NewQuestionService( @@ -106,6 +108,7 @@ func NewQuestionService( newQuestionNotificationService *notification.ExternalNotificationService, reviewService *review.ReviewService, configService *config.ConfigService, + eventQueueService event_queue.EventQueueService, ) *QuestionService { return &QuestionService{ questionRepo: questionRepo, @@ -127,6 +130,7 @@ func NewQuestionService( newQuestionNotificationService: newQuestionNotificationService, reviewService: reviewService, configService: configService, + eventQueueService: eventQueueService, } } @@ -385,6 +389,8 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question qs.externalNotificationQueueService.Send(ctx, schema.CreateNewQuestionNotificationMsg(question.ID, question.Title, question.UserID, tags)) } + qs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionCreate, req.UserID). + QID(question.ID, question.UserID)) questionInfo, err = qs.GetQuestion(ctx, question.ID, question.UserID, req.QuestionPermission) return @@ -546,6 +552,8 @@ func (qs *QuestionService) RemoveQuestion(ctx context.Context, req *schema.Remov OriginalObjectID: questionInfo.ID, ActivityTypeKey: constant.ActQuestionDeleted, }) + qs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionDelete, req.UserID). + QID(questionInfo.ID, questionInfo.UserID)) return nil } @@ -937,6 +945,8 @@ func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.Quest RevisionID: revisionID, OriginalObjectID: question.ID, }) + qs.eventQueueService.Send(ctx, schema.NewEvent(constant.EventQuestionUpdate, req.UserID). + QID(question.ID, question.UserID)) } questionInfo, err = qs.GetQuestion(ctx, question.ID, question.UserID, req.QuestionPermission) diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index 11f3bb63b..2a6122ae7 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/apache/incubator-answer/internal/service/event_queue" "time" "github.com/apache/incubator-answer/internal/base/constant" @@ -65,6 +66,7 @@ type UserService struct { userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo userNotificationConfigService *user_notification_config.UserNotificationConfigService questionService *questioncommon.QuestionCommon + eventQueueService event_queue.EventQueueService } func NewUserService(userRepo usercommon.UserRepo, @@ -79,6 +81,7 @@ func NewUserService(userRepo usercommon.UserRepo, userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo, userNotificationConfigService *user_notification_config.UserNotificationConfigService, questionService *questioncommon.QuestionCommon, + eventQueueService event_queue.EventQueueService, ) *UserService { return &UserService{ userCommonService: userCommonService, @@ -93,6 +96,7 @@ func NewUserService(userRepo usercommon.UserRepo, userNotificationConfigRepo: userNotificationConfigRepo, userNotificationConfigService: userNotificationConfigService, questionService: questionService, + eventQueueService: eventQueueService, } } @@ -352,6 +356,10 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req, siteUsers) err = us.userRepo.UpdateInfo(ctx, cond) + if err != nil { + return nil, err + } + us.eventQueueService.Send(ctx, schema.NewEvent(constant.EventUserUpdate, req.UserID)) return nil, err } diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go index bfb403c9f..c83011a9c 100644 --- a/internal/service/content/vote_service.go +++ b/internal/service/content/vote_service.go @@ -21,6 +21,8 @@ package content import ( "context" + "fmt" + "github.com/apache/incubator-answer/internal/service/event_queue" "strings" "github.com/apache/incubator-answer/internal/service/activity_common" @@ -62,6 +64,7 @@ type VoteService struct { commentCommonRepo comment_common.CommentCommonRepo objectService *object_info.ObjService activityRepo activity_common.ActivityRepo + eventQueueService event_queue.EventQueueService } func NewVoteService( @@ -71,6 +74,7 @@ func NewVoteService( answerRepo answercommon.AnswerRepo, commentCommonRepo comment_common.CommentCommonRepo, objectService *object_info.ObjService, + eventQueueService event_queue.EventQueueService, ) *VoteService { return &VoteService{ voteRepo: voteRepo, @@ -79,6 +83,7 @@ func NewVoteService( answerRepo: answerRepo, commentCommonRepo: commentCommonRepo, objectService: objectService, + eventQueueService: eventQueueService, } } @@ -112,6 +117,9 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s return nil, err } err = vs.voteRepo.Vote(ctx, voteUpOperationInfo) + if err != nil { + return nil, err + } } if err != nil { return nil, err @@ -125,6 +133,7 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s resp.Votes = resp.UpVotes - resp.DownVotes if !req.IsCancel { resp.VoteStatus = constant.ActVoteUp + vs.sendEvent(ctx, req, objectInfo, resp) } return resp, nil } @@ -173,6 +182,7 @@ func (vs *VoteService) VoteDown(ctx context.Context, req *schema.VoteReq) (resp resp.Votes = resp.UpVotes - resp.DownVotes if !req.IsCancel { resp.VoteStatus = constant.ActVoteDown + vs.sendEvent(ctx, req, objectInfo, resp) } return resp, nil } @@ -289,3 +299,24 @@ func (vs *VoteService) getActivities(ctx context.Context, op *schema.VoteOperati } return activities } + +func (vs *VoteService) sendEvent(ctx context.Context, + req *schema.VoteReq, objectInfo *schema.SimpleObjectInfo, resp *schema.VoteResp) { + var event *schema.EventMsg + switch objectInfo.ObjectType { + case constant.QuestionObjectType: + event = schema.NewEvent(constant.EventQuestionVote, req.UserID). + QID(objectInfo.QuestionID, objectInfo.ObjectCreatorUserID) + case constant.AnswerObjectType: + event = schema.NewEvent(constant.EventAnswerVote, req.UserID). + AID(objectInfo.AnswerID, objectInfo.ObjectCreatorUserID) + case constant.CommentObjectType: + event = schema.NewEvent(constant.EventCommentVote, req.UserID). + CID(objectInfo.CommentID, objectInfo.ObjectCreatorUserID) + default: + return + } + event.AddExtra("vote_up_amount", fmt.Sprintf("%d", resp.UpVotes)) + event.AddExtra("vote_down_amount", fmt.Sprintf("%d", resp.DownVotes)) + vs.eventQueueService.Send(ctx, event) +} diff --git a/internal/service/event_queue/event_queue.go b/internal/service/event_queue/event_queue.go new file mode 100644 index 000000000..b89a3ccc4 --- /dev/null +++ b/internal/service/event_queue/event_queue.go @@ -0,0 +1,69 @@ +/* + * 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 event_queue + +import ( + "context" + + "github.com/apache/incubator-answer/internal/schema" + "github.com/segmentfault/pacman/log" +) + +type EventQueueService interface { + Send(ctx context.Context, msg *schema.EventMsg) + RegisterHandler(handler func(ctx context.Context, msg *schema.EventMsg) error) +} + +type eventQueueService struct { + Queue chan *schema.EventMsg + Handler func(ctx context.Context, msg *schema.EventMsg) error +} + +func (ns *eventQueueService) Send(ctx context.Context, msg *schema.EventMsg) { + ns.Queue <- msg +} + +func (ns *eventQueueService) RegisterHandler( + handler func(ctx context.Context, msg *schema.EventMsg) error) { + ns.Handler = handler +} + +func (ns *eventQueueService) working() { + go func() { + for msg := range ns.Queue { + log.Debugf("received badge %+v", msg) + if ns.Handler == nil { + log.Warnf("no handler for badge") + continue + } + if err := ns.Handler(context.Background(), msg); err != nil { + log.Error(err) + } + } + }() +} + +// NewEventQueueService create a new badge queue service +func NewEventQueueService() EventQueueService { + ns := &eventQueueService{} + ns.Queue = make(chan *schema.EventMsg, 128) + ns.working() + return ns +} diff --git a/internal/service/meta/meta_service.go b/internal/service/meta/meta_service.go index 1026b1733..778c6ca66 100644 --- a/internal/service/meta/meta_service.go +++ b/internal/service/meta/meta_service.go @@ -23,6 +23,7 @@ import ( "context" "encoding/json" "errors" + "github.com/apache/incubator-answer/internal/service/event_queue" "strconv" "strings" @@ -46,14 +47,22 @@ type MetaService struct { userCommon *usercommon.UserCommon questionRepo questioncommon.QuestionRepo answerRepo answercommon.AnswerRepo + eventQueueService event_queue.EventQueueService } -func NewMetaService(metaCommonService *metacommon.MetaCommonService, userCommon *usercommon.UserCommon, answerRepo answercommon.AnswerRepo, questionRepo questioncommon.QuestionRepo) *MetaService { +func NewMetaService( + metaCommonService *metacommon.MetaCommonService, + userCommon *usercommon.UserCommon, + answerRepo answercommon.AnswerRepo, + questionRepo questioncommon.QuestionRepo, + eventQueueService event_queue.EventQueueService, +) *MetaService { return &MetaService{ metaCommonService: metaCommonService, questionRepo: questionRepo, userCommon: userCommon, answerRepo: answerRepo, + eventQueueService: eventQueueService, } } @@ -86,22 +95,27 @@ func (ms *MetaService) AddOrUpdateReaction(ctx context.Context, req *schema.Upda if err != nil { return nil, err } + var event *schema.EventMsg if objectType == constant.AnswerObjectType { - _, exist, err := ms.answerRepo.GetAnswer(ctx, req.ObjectID) + answerInfo, exist, err := ms.answerRepo.GetAnswer(ctx, req.ObjectID) if err != nil { return nil, err } if !exist { return nil, myErrors.BadRequest(reason.AnswerNotFound) } + event = schema.NewEvent(constant.EventAnswerReact, req.UserID). + AID(answerInfo.ID, answerInfo.UserID) } else if objectType == constant.QuestionObjectType { - _, exist, err := ms.questionRepo.GetQuestion(ctx, req.ObjectID) + questionInfo, exist, err := ms.questionRepo.GetQuestion(ctx, req.ObjectID) if err != nil { return nil, err } if !exist { return nil, myErrors.BadRequest(reason.QuestionNotFound) } + event = schema.NewEvent(constant.EventQuestionReact, req.UserID). + QID(questionInfo.ID, questionInfo.UserID) } else { return nil, myErrors.BadRequest(reason.ObjectNotFound) } @@ -138,7 +152,7 @@ func (ms *MetaService) AddOrUpdateReaction(ctx context.Context, req *schema.Upda if err != nil { return nil, err } - + ms.eventQueueService.Send(ctx, event) return resp, nil } diff --git a/internal/service/notification/notification_service.go b/internal/service/notification/notification_service.go index 71febb677..b73d4fdad 100644 --- a/internal/service/notification/notification_service.go +++ b/internal/service/notification/notification_service.go @@ -23,7 +23,7 @@ import ( "context" "encoding/json" "fmt" - + "github.com/apache/incubator-answer/internal/service/badge" "github.com/apache/incubator-answer/internal/service/report_common" "github.com/apache/incubator-answer/internal/service/review" usercommon "github.com/apache/incubator-answer/internal/service/user_common" @@ -52,6 +52,7 @@ type NotificationService struct { reportRepo report_common.ReportRepo reviewService *review.ReviewService userRepo usercommon.UserRepo + badgeRepo badge.BadgeRepo } func NewNotificationService( @@ -62,6 +63,7 @@ func NewNotificationService( userRepo usercommon.UserRepo, reportRepo report_common.ReportRepo, reviewService *review.ReviewService, + badgeRepo badge.BadgeRepo, ) *NotificationService { return &NotificationService{ data: data, @@ -71,35 +73,60 @@ func NewNotificationService( userRepo: userRepo, reportRepo: reportRepo, reviewService: reviewService, + badgeRepo: badgeRepo, } } func (ns *NotificationService) GetRedDot(ctx context.Context, req *schema.GetRedDot) (resp *schema.RedDot, err error) { + inboxKey := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeInbox, req.UserID) + achievementKey := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeAchievement, req.UserID) + redBot := &schema.RedDot{} - inboxKey := fmt.Sprintf("answer_RedDot_%d_%s", schema.NotificationTypeInbox, req.UserID) - achievementKey := fmt.Sprintf("answer_RedDot_%d_%s", schema.NotificationTypeAchievement, req.UserID) - inboxValue, _, err := ns.data.Cache.GetInt64(ctx, inboxKey) - if err != nil { - redBot.Inbox = 0 - } else { - redBot.Inbox = inboxValue - } - achievementValue, _, err := ns.data.Cache.GetInt64(ctx, achievementKey) - if err != nil { - redBot.Achievement = 0 - } else { - redBot.Achievement = achievementValue - } - revisionCount := &schema.RevisionSearch{} - _ = copier.Copy(revisionCount, req) + redBot.Inbox, _, err = ns.data.Cache.GetInt64(ctx, inboxKey) + redBot.Achievement, _, err = ns.data.Cache.GetInt64(ctx, achievementKey) + + // get review amount if req.CanReviewAnswer || req.CanReviewQuestion || req.CanReviewTag { redBot.CanRevision = true redBot.Revision = ns.countAllReviewAmount(ctx, req) } + // get badge award + redBot.BadgeAward = ns.getBadgeAward(ctx, req.UserID) return redBot, nil } +func (ns *NotificationService) getBadgeAward(ctx context.Context, userID string) (badgeAward *schema.RedDotBadgeAward) { + key := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeBadgeAchievement, userID) + cacheData, exist, err := ns.data.Cache.GetString(ctx, key) + if err != nil { + log.Errorf("get badge award failed: %v", err) + return nil + } + if !exist { + return nil + } + + c := schema.NewRedDotBadgeAwardCache() + c.FromJSON(cacheData) + award := c.GetBadgeAward() + if award == nil { + return nil + } + badgeInfo, exists, err := ns.badgeRepo.GetByID(ctx, award.BadgeID) + if err != nil { + log.Errorf("get badge info failed: %v", err) + return nil + } + if !exists { + return nil + } + award.Name = translator.Tr(handler.GetLangByCtx(ctx), badgeInfo.Name) + award.Icon = badgeInfo.Icon + award.Level = badgeInfo.Level + return award +} + func (ns *NotificationService) countAllReviewAmount(ctx context.Context, req *schema.GetRedDot) (amount int64) { // get queue amount if req.IsAdmin { @@ -137,21 +164,16 @@ func (ns *NotificationService) countAllReviewAmount(ctx context.Context, req *sc } func (ns *NotificationService) ClearRedDot(ctx context.Context, req *schema.NotificationClearRequest) (*schema.RedDot, error) { - botType, ok := schema.NotificationType[req.TypeStr] - if ok { - key := fmt.Sprintf("answer_RedDot_%d_%s", botType, req.UserID) - err := ns.data.Cache.Del(ctx, key) - if err != nil { - log.Error("ClearRedDot del cache error", err.Error()) - } - } - getRedDotreq := &schema.GetRedDot{} - _ = copier.Copy(getRedDotreq, req) - return ns.GetRedDot(ctx, getRedDotreq) + key := fmt.Sprintf(constant.RedDotCacheKey, req.NotificationType, req.UserID) + _ = ns.data.Cache.Del(ctx, key) + + resp := &schema.GetRedDot{} + _ = copier.Copy(resp, req) + return ns.GetRedDot(ctx, resp) } -func (ns *NotificationService) ClearUnRead(ctx context.Context, userID string, botTypeStr string) error { - botType, ok := schema.NotificationType[botTypeStr] +func (ns *NotificationService) ClearUnRead(ctx context.Context, userID string, notificationType string) error { + botType, ok := schema.NotificationType[notificationType] if ok { err := ns.notificationRepo.ClearUnRead(ctx, userID, botType) if err != nil { @@ -164,19 +186,23 @@ func (ns *NotificationService) ClearUnRead(ctx context.Context, userID string, b func (ns *NotificationService) ClearIDUnRead(ctx context.Context, userID string, id string) error { notificationInfo, exist, err := ns.notificationRepo.GetById(ctx, id) if err != nil { - log.Error("notificationRepo.GetById error", err.Error()) + log.Errorf("get notification failed: %v", err) return nil } - if !exist { + if !exist || notificationInfo.UserID != userID { return nil } - if notificationInfo.UserID == userID && notificationInfo.IsRead == schema.NotificationNotRead { + if notificationInfo.IsRead == schema.NotificationNotRead { err := ns.notificationRepo.ClearIDUnRead(ctx, userID, id) if err != nil { return err } } + err = ns.notificationCommon.RemoveBadgeAwardAlertCache(ctx, userID, id) + if err != nil { + log.Errorf("remove badge award alert cache failed: %v", err) + } return nil } @@ -224,6 +250,14 @@ func (ns *NotificationService) formatNotificationPage(ctx context.Context, notif item.NotificationAction == constant.NotificationDownVotedTheAnswer { item.UserInfo = nil } + // If notification is badge, the user info is not needed and the title need to be translated. + if item.ObjectInfo.ObjectType == constant.BadgeAwardObjectType { + badgeName := translator.Tr(lang, item.ObjectInfo.Title) + item.ObjectInfo.Title = translator.TrWithData(lang, constant.NotificationEarnedBadge, struct { + BadgeName string + }{BadgeName: badgeName}) + item.UserInfo = nil + } item.ID = notificationInfo.ID item.NotificationAction = translator.Tr(lang, item.NotificationAction) diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index 319403b24..a3129b3a4 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -103,7 +103,7 @@ func NewNotificationCommon( // ObjectInfo.Title // ObjectInfo.ObjectID // ObjectInfo.ObjectType -func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.NotificationMsg) error { +func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.NotificationMsg) (err error) { if msg.Type == schema.NotificationTypeAchievement && plugin.RankAgentEnabled() { return nil } @@ -119,17 +119,25 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N Type: msg.Type, } var questionID string // just for notify all followers - objInfo, err := ns.objectInfoService.GetInfo(ctx, req.ObjectInfo.ObjectID) - if err != nil { - log.Error(err) - } else { - req.ObjectInfo.Title = objInfo.Title - questionID = objInfo.QuestionID + var objInfo *schema.SimpleObjectInfo + if msg.ObjectType == constant.BadgeAwardObjectType { + req.ObjectInfo.Title = msg.Title objectMap := make(map[string]string) - objectMap["question"] = uid.DeShortID(objInfo.QuestionID) - objectMap["answer"] = uid.DeShortID(objInfo.AnswerID) - objectMap["comment"] = objInfo.CommentID + objectMap["badge_id"] = msg.ExtraInfo["badge_id"] req.ObjectInfo.ObjectMap = objectMap + } else { + objInfo, err = ns.objectInfoService.GetInfo(ctx, req.ObjectInfo.ObjectID) + if err != nil { + log.Error(err) + } else { + req.ObjectInfo.Title = objInfo.Title + questionID = objInfo.QuestionID + objectMap := make(map[string]string) + objectMap["question"] = uid.DeShortID(objInfo.QuestionID) + objectMap["answer"] = uid.DeShortID(objInfo.AnswerID) + objectMap["comment"] = objInfo.CommentID + req.ObjectInfo.ObjectMap = objectMap + } } if msg.Type == schema.NotificationTypeAchievement { @@ -188,10 +196,13 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N if err != nil { return fmt.Errorf("add notification error: %w", err) } - err = ns.addRedDot(ctx, info.UserID, info.Type) + err = ns.addRedDot(ctx, info.UserID, msg.Type) if err != nil { log.Error("addRedDot Error", err.Error()) } + if req.ObjectInfo.ObjectType == constant.BadgeAwardObjectType { + err = ns.AddBadgeAwardAlertCache(ctx, info.UserID, info.ID, req.ObjectInfo.ObjectMap["badge_id"]) + } go ns.SendNotificationToAllFollower(ctx, msg, questionID) @@ -201,19 +212,67 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N return nil } -func (ns *NotificationCommon) addRedDot(ctx context.Context, userID string, botType int) error { - key := fmt.Sprintf("answer_RedDot_%d_%s", botType, userID) - err := ns.data.Cache.SetInt64(ctx, key, 1, 30*24*time.Hour) //Expiration time is one month. +func (ns *NotificationCommon) addRedDot(ctx context.Context, userID string, noticeType int) error { + var key string + if noticeType == schema.NotificationTypeInbox { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeInbox, userID) + } else { + key = fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeAchievement, userID) + } + err := ns.data.Cache.SetInt64(ctx, key, 1, constant.RedDotCacheTime) if err != nil { return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() } return nil } +// AddBadgeAwardAlertCache add badge award alert cache +func (ns *NotificationCommon) AddBadgeAwardAlertCache(ctx context.Context, userID, notificationID, badgeID string) (err error) { + key := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeBadgeAchievement, userID) + cacheData, exist, err := ns.data.Cache.GetString(ctx, key) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if !exist { + c := schema.NewRedDotBadgeAwardCache() + c.AddBadgeAward(&schema.RedDotBadgeAward{ + NotificationID: notificationID, + BadgeID: badgeID, + }) + return ns.data.Cache.SetString(ctx, key, c.ToJSON(), constant.RedDotCacheTime) + } + c := schema.NewRedDotBadgeAwardCache() + c.FromJSON(cacheData) + c.AddBadgeAward(&schema.RedDotBadgeAward{ + NotificationID: notificationID, + BadgeID: badgeID, + }) + return ns.data.Cache.SetString(ctx, key, c.ToJSON(), constant.RedDotCacheTime) +} + +// RemoveBadgeAwardAlertCache remove badge award alert cache +func (ns *NotificationCommon) RemoveBadgeAwardAlertCache(ctx context.Context, userID, notificationID string) (err error) { + key := fmt.Sprintf(constant.RedDotCacheKey, constant.NotificationTypeBadgeAchievement, userID) + cacheData, exist, err := ns.data.Cache.GetString(ctx, key) + if err != nil { + return errors.InternalServer(reason.UnknownError).WithError(err).WithStack() + } + if !exist { + return nil + } + c := schema.NewRedDotBadgeAwardCache() + c.FromJSON(cacheData) + c.RemoveBadgeAward(notificationID) + if len(c.BadgeAwardList) == 0 { + return ns.data.Cache.Del(ctx, key) + } + return ns.data.Cache.SetString(ctx, key, c.ToJSON(), constant.RedDotCacheTime) +} + // SendNotificationToAllFollower send notification to all followers func (ns *NotificationCommon) SendNotificationToAllFollower(ctx context.Context, msg *schema.NotificationMsg, questionID string) { - if msg.NoNeedPushAllFollow { + if msg.NoNeedPushAllFollow || len(questionID) == 0 { return } if msg.NotificationAction != constant.NotificationUpdateQuestion && diff --git a/internal/service/object_info/object_info.go b/internal/service/object_info/object_info.go index 6c2e89a9d..9a85f07e8 100644 --- a/internal/service/object_info/object_info.go +++ b/internal/service/object_info/object_info.go @@ -21,7 +21,6 @@ package object_info import ( "context" - "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/reason" "github.com/apache/incubator-answer/internal/schema" diff --git a/internal/service/provider.go b/internal/service/provider.go index e82d93167..12e0db797 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -26,6 +26,7 @@ import ( "github.com/apache/incubator-answer/internal/service/activity_queue" answercommon "github.com/apache/incubator-answer/internal/service/answer_common" "github.com/apache/incubator-answer/internal/service/auth" + "github.com/apache/incubator-answer/internal/service/badge" "github.com/apache/incubator-answer/internal/service/collection" collectioncommon "github.com/apache/incubator-answer/internal/service/collection_common" "github.com/apache/incubator-answer/internal/service/comment" @@ -33,6 +34,7 @@ import ( "github.com/apache/incubator-answer/internal/service/config" "github.com/apache/incubator-answer/internal/service/content" "github.com/apache/incubator-answer/internal/service/dashboard" + "github.com/apache/incubator-answer/internal/service/event_queue" "github.com/apache/incubator-answer/internal/service/export" "github.com/apache/incubator-answer/internal/service/follow" "github.com/apache/incubator-answer/internal/service/meta" @@ -117,4 +119,9 @@ var ProviderSetService = wire.NewSet( notice_queue.NewNewQuestionNotificationQueueService, review.NewReviewService, meta.NewMetaService, + event_queue.NewEventQueueService, + badge.NewBadgeService, + badge.NewBadgeEventService, + badge.NewBadgeAwardService, + badge.NewBadgeGroupService, ) diff --git a/internal/service/report/report_service.go b/internal/service/report/report_service.go index 7f060a39f..218423e13 100644 --- a/internal/service/report/report_service.go +++ b/internal/service/report/report_service.go @@ -21,6 +21,7 @@ package report import ( "encoding/json" + "github.com/apache/incubator-answer/internal/service/event_queue" "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/handler" @@ -55,6 +56,7 @@ type ReportService struct { commentCommonRepo comment_common.CommentCommonRepo reportHandle *report_handle.ReportHandle configService *config.ConfigService + eventQueueService event_queue.EventQueueService } // NewReportService new report service @@ -67,6 +69,7 @@ func NewReportService( commentCommonRepo comment_common.CommentCommonRepo, reportHandle *report_handle.ReportHandle, configService *config.ConfigService, + eventQueueService event_queue.EventQueueService, ) *ReportService { return &ReportService{ reportRepo: reportRepo, @@ -77,6 +80,7 @@ func NewReportService( commentCommonRepo: commentCommonRepo, reportHandle: reportHandle, configService: configService, + eventQueueService: eventQueueService, } } @@ -112,7 +116,12 @@ func (rs *ReportService) AddReport(ctx context.Context, req *schema.AddReportReq Content: req.Content, Status: entity.ReportStatusPending, } - return rs.reportRepo.AddReport(ctx, report) + err = rs.reportRepo.AddReport(ctx, report) + if err != nil { + return err + } + rs.sendEvent(ctx, report, objInfo) + return nil } // GetUnreviewedReportPostPage get unreviewed report post page @@ -218,3 +227,22 @@ func (rs *ReportService) ReviewReport(ctx context.Context, req *schema.ReviewRep return rs.reportRepo.UpdateStatus(ctx, report.ID, entity.ReportStatusCompleted) } + +func (rs *ReportService) sendEvent(ctx context.Context, + report *entity.Report, objectInfo *schema.SimpleObjectInfo) { + var event *schema.EventMsg + switch objectInfo.ObjectType { + case constant.QuestionObjectType: + event = schema.NewEvent(constant.EventQuestionFlag, report.UserID). + QID(objectInfo.QuestionID, objectInfo.ObjectCreatorUserID) + case constant.AnswerObjectType: + event = schema.NewEvent(constant.EventAnswerFlag, report.UserID). + AID(objectInfo.AnswerID, objectInfo.ObjectCreatorUserID) + case constant.CommentObjectType: + event = schema.NewEvent(constant.EventCommentFlag, report.UserID). + CID(objectInfo.CommentID, objectInfo.ObjectCreatorUserID) + default: + return + } + rs.eventQueueService.Send(ctx, event) +} diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index c846d4ddc..d52f2b877 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -93,6 +93,9 @@ export const ADMIN_NAV_MENUS = [ { name: 'users', }, + { + name: 'badges', + }, { name: 'customize', children: [ diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index ff7c09027..41dc0fef0 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -328,6 +328,8 @@ export type UserFilterBy = | 'suspended' | 'deleted'; +export type BadgeFilterBy = 'all' | 'active' | 'inactive'; + export type InstalledPluginsFilterBy = | 'all' | 'active' diff --git a/ui/src/pages/Admin/Badges/components/Action/index.tsx b/ui/src/pages/Admin/Badges/components/Action/index.tsx new file mode 100644 index 000000000..77601cbb4 --- /dev/null +++ b/ui/src/pages/Admin/Badges/components/Action/index.tsx @@ -0,0 +1,51 @@ +/* + * 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. + */ + +import { Dropdown } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { Icon } from '@/components'; + +interface Props { + badgeData; +} + +const UserOperation = ({ badgeData }: Props) => { + const { t } = useTranslation('translation', { keyPrefix: 'admin.badges' }); + + console.log(badgeData); + + return ( + + + + + + + {t('active')} + {t('deactivate')} + + {t('show_logs')} + + + + ); +}; + +export default UserOperation; diff --git a/ui/src/pages/Admin/Badges/index.tsx b/ui/src/pages/Admin/Badges/index.tsx new file mode 100644 index 000000000..288540123 --- /dev/null +++ b/ui/src/pages/Admin/Badges/index.tsx @@ -0,0 +1,122 @@ +/* + * 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. + */ + +import { FC } from 'react'; +import { Form, Table, Stack } from 'react-bootstrap'; +import { useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { QueryGroup } from '@/components'; +import * as Type from '@/common/interface'; + +import Action from './components/Action'; + +const BadgeFilterKeys: Type.BadgeFilterBy[] = ['all', 'active', 'inactive']; + +// const bgMap = { +// normal: 'text-bg-success', +// suspended: 'text-bg-danger', +// deleted: 'text-bg-danger', +// inactive: 'text-bg-secondary', +// }; + +const Users: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'admin.badges' }); + + const [urlSearchParams, setUrlSearchParams] = useSearchParams(); + const curFilter = urlSearchParams.get('filter') || BadgeFilterKeys[0]; + const curQuery = urlSearchParams.get('query') || ''; + + const handleFilter = (e) => { + urlSearchParams.set('query', e.target.value); + urlSearchParams.delete('page'); + setUrlSearchParams(urlSearchParams); + }; + + return ( + <> +

{t('title')}

+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
{t('name')}{t('group')}{t('awards')}{t('status')}{t('action')}
+ badge +
+
Nice Question
+
Question score of 10 or more.
+
+
Community Badges200Active
+ {/* {Number(data?.count) <= 0 && !isLoading && } */} + {/*
+ +
*/} + + ); +}; + +export default Users; diff --git a/ui/src/pages/Admin/index.tsx b/ui/src/pages/Admin/index.tsx index b527088f9..5da0780d2 100644 --- a/ui/src/pages/Admin/index.tsx +++ b/ui/src/pages/Admin/index.tsx @@ -37,6 +37,7 @@ const g10Paths = [ 'questions', 'answers', 'users', + 'badges', 'flags', 'installed-plugins', ]; diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 5c420e369..bd4a0c796 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -400,6 +400,10 @@ const routes: RouteNode[] = [ path: ':slug_name', page: 'pages/Admin/Plugins/Config', }, + { + path: 'badges', + page: 'pages/Admin/Badges', + }, ], }, {