diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 446f6cc0b..0a0567ff0 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -38,6 +38,7 @@ import ( "github.com/apache/answer/internal/controller_admin" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/admin_message" "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" "github.com/apache/answer/internal/repo/api_key" @@ -75,6 +76,7 @@ import ( activity2 "github.com/apache/answer/internal/service/activity" activity_common2 "github.com/apache/answer/internal/service/activity_common" "github.com/apache/answer/internal/service/activityqueue" + admin_message2 "github.com/apache/answer/internal/service/admin_message" ai_conversation2 "github.com/apache/answer/internal/service/ai_conversation" "github.com/apache/answer/internal/service/answer_common" "github.com/apache/answer/internal/service/apikey" @@ -293,7 +295,10 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, aiController := controller.NewAIController(searchService, siteInfoCommonService, tagCommonService, questionCommon, commentRepo, userCommon, answerRepo, mcpController, aiConversationService, featureToggleService) aiConversationController := controller.NewAIConversationController(aiConversationService, featureToggleService) aiConversationAdminController := controller_admin.NewAIConversationAdminController(aiConversationService, featureToggleService) - 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, adminAPIKeyController, aiController, aiConversationController, aiConversationAdminController, mcpController) + adminMessageRepo := admin_message.NewAdminMessageRepo(dataData) + adminMessageService := admin_message2.NewAdminMessageService(adminMessageRepo, uniqueIDRepo, userCommon, userRoleRelService, objService, noticequeueService) + adminMessageController := controller.NewAdminMessageController(adminMessageService) + 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, adminAPIKeyController, aiController, aiConversationController, aiConversationAdminController, mcpController, adminMessageController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..eca7da9e2 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -482,6 +482,8 @@ backend: other: invited you to answer earned_badge: other: You've earned the "{{.BadgeName}}" badge + admin_message: + other: sent you a message email_tpl: change_email: title: @@ -877,11 +879,13 @@ ui: all_read: Mark all as read show_more: Show more someone: Someone + message_context: Go to the post this message is about inbox_type: all: All posts: Posts invites: Invites votes: Votes + messages: Messages answer: Answer question: Question badge_award: Badge @@ -1840,11 +1844,25 @@ ui: ai_assistant: AI Assistant ai_settings: AI Settings mcp: MCP + admin_messages: Admin messages website_welcome: Welcome to {{site_name}} user_center: login: Login qrcode_login_tip: Please use {{ agentName }} to scan the QR code and log in. login_failed_email_tip: Login failed, please allow this app to access your email information before try again. + admin_message: + btn: Write a message + title: Message to a user + to: To + to_placeholder: username + context: About + subject: Subject + subject_placeholder: short subject + body: Message + body_placeholder: The user will see this in Notifications → Messages + send: Send + success: "Message sent to {{username}}." + failed: Could not send the message badges: modal: title: Congratulations @@ -2304,6 +2322,23 @@ ui: msg: should_be_number: the input should be number number_larger_1: number should be equal or larger than 1 + admin_messages: + title: Admin messages + total: "{{count}} messages" + unread: unread + context: About + filter: + user: User + user_placeholder: username + range: Sent between + search: Search + search_placeholder: text, name or e-mail + col: + sent: Sent + from: From + to: To + message: Message + read: Read badges: action: Action active: Active diff --git a/internal/base/constant/notification.go b/internal/base/constant/notification.go index 9a7762d8e..e8728ec0c 100644 --- a/internal/base/constant/notification.go +++ b/internal/base/constant/notification.go @@ -58,6 +58,8 @@ const ( NotificationInvitedYouToAnswer = "notification.action.invited_you_to_answer" // NotificationEarnedBadge earned badge NotificationEarnedBadge = "notification.action.earned_badge" + // NotificationAdminMessage a message written by an admin / moderator + NotificationAdminMessage = "notification.action.admin_message" ) type NotificationChannelKey string @@ -99,5 +101,6 @@ var ( NotificationYourAnswerWasDeleted: 1, NotificationYourCommentWasDeleted: 1, NotificationInvitedYouToAnswer: 3, + NotificationAdminMessage: 4, } ) diff --git a/internal/base/constant/object_type.go b/internal/base/constant/object_type.go index e4ac3d20c..7939d16c7 100644 --- a/internal/base/constant/object_type.go +++ b/internal/base/constant/object_type.go @@ -20,28 +20,30 @@ package constant const ( - QuestionObjectType = "question" - AnswerObjectType = "answer" - TagObjectType = "tag" - UserObjectType = "user" - CollectionObjectType = "collection" - CommentObjectType = "comment" - ReportObjectType = "report" - BadgeObjectType = "badge" - BadgeAwardObjectType = "badge_award" + QuestionObjectType = "question" + AnswerObjectType = "answer" + TagObjectType = "tag" + UserObjectType = "user" + CollectionObjectType = "collection" + CommentObjectType = "comment" + ReportObjectType = "report" + BadgeObjectType = "badge" + BadgeAwardObjectType = "badge_award" + AdminMessageObjectType = "admin_message" // notification object type of admin messages ) var ( ObjectTypeStrMapping = map[string]int{ - QuestionObjectType: 1, - AnswerObjectType: 2, - TagObjectType: 3, - UserObjectType: 4, - CollectionObjectType: 6, - CommentObjectType: 7, - ReportObjectType: 8, - BadgeObjectType: 9, - BadgeAwardObjectType: 10, + QuestionObjectType: 1, + AnswerObjectType: 2, + TagObjectType: 3, + UserObjectType: 4, + CollectionObjectType: 6, + CommentObjectType: 7, + ReportObjectType: 8, + BadgeObjectType: 9, + BadgeAwardObjectType: 10, + AdminMessageObjectType: 11, } ObjectTypeNumberMapping = map[int]string{ diff --git a/internal/controller/admin_message_controller.go b/internal/controller/admin_message_controller.go new file mode 100644 index 000000000..c4d0fa913 --- /dev/null +++ b/internal/controller/admin_message_controller.go @@ -0,0 +1,74 @@ +/* + * 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/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/admin_message" + "github.com/gin-gonic/gin" +) + +// AdminMessageController messages from admins / moderators to users (role checked in the service) +type AdminMessageController struct { + adminMessageService *admin_message.AdminMessageService +} + +// NewAdminMessageController new controller +func NewAdminMessageController(adminMessageService *admin_message.AdminMessageService) *AdminMessageController { + return &AdminMessageController{adminMessageService: adminMessageService} +} + +// Send write a message to a user +// @Summary send a message to a user (admin / moderator) +// @Tags admin-message +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.SendAdminMessageReq true "message" +// @Success 200 {object} handler.RespBody{data=schema.AdminMessageItem} +// @Router /answer/api/v1/admin-message [post] +func (c *AdminMessageController) Send(ctx *gin.Context) { + req := &schema.SendAdminMessageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + resp, err := c.adminMessageService.Send(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// Page list sent messages +// @Summary list messages (admin / moderator) +// @Tags admin-message +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.AdminMessageItem}} +// @Router /answer/api/v1/admin-message/page [get] +func (c *AdminMessageController) Page(ctx *gin.Context) { + req := &schema.AdminMessagePageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx) + resp, err := c.adminMessageService.Page(ctx, req) + handler.HandleResponse(ctx, err, resp) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index c31763bea..bf557d5ef 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -52,6 +52,7 @@ var ProviderSetController = wire.NewSet( NewMetaController, NewEmbedController, NewBadgeController, + NewAdminMessageController, NewRenderController, NewSidebarController, NewMCPController, diff --git a/internal/entity/admin_message_entity.go b/internal/entity/admin_message_entity.go new file mode 100644 index 000000000..42b80875a --- /dev/null +++ b/internal/entity/admin_message_entity.go @@ -0,0 +1,42 @@ +/* + * 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" + +// AdminMessage a message written by an admin / moderator to one user, delivered as a notification +type AdminMessage struct { + ID string `xorm:"not null pk BIGINT(20) id"` // unique id (type 11) — a plain autoincrement would be mangled by uid.DeShortID in the notification path + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP index created_at"` + SenderUserID string `xorm:"not null default 0 BIGINT(20) index sender_user_id"` + ReceiverUserID string `xorm:"not null default 0 BIGINT(20) index receiver_user_id"` + Title string `xorm:"not null default '' VARCHAR(200) title"` + Body string `xorm:"TEXT body"` + ObjectType string `xorm:"not null default '' VARCHAR(32) object_type"` + ObjectID string `xorm:"not null default 0 BIGINT(20) object_id"` + QuestionID string `xorm:"not null default 0 BIGINT(20) question_id"` + AnswerID string `xorm:"not null default 0 BIGINT(20) answer_id"` + ReadAt *time.Time `xorm:"TIMESTAMP read_at"` +} + +// TableName admin_message table name +func (AdminMessage) TableName() string { + return "admin_message" +} diff --git a/internal/repo/admin_message/admin_message_repo.go b/internal/repo/admin_message/admin_message_repo.go new file mode 100644 index 000000000..2cd8f8a47 --- /dev/null +++ b/internal/repo/admin_message/admin_message_repo.go @@ -0,0 +1,121 @@ +/* + * 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 admin_message + +import ( + "context" + "time" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/admin_message" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "xorm.io/builder" +) + +type adminMessageRepo struct { + data *data.Data +} + +// NewAdminMessageRepo new repository; the table is created on start-up +func NewAdminMessageRepo(data *data.Data) admin_message.AdminMessageRepo { + if err := data.DB.Sync2(new(entity.AdminMessage)); err != nil { + log.Errorf("admin_message: sync table: %v", err) + } + return &adminMessageRepo{data: data} +} + +// Add inserts a message and fills its ID +func (r *adminMessageRepo) Add(ctx context.Context, m *entity.AdminMessage) error { + if _, err := r.data.DB.Context(ctx).Insert(m); err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +// Page newest first with filters; userIDs restricts sender or receiver, textUserIDs / text are the free-text search +func (r *adminMessageRepo) Page(ctx context.Context, from, to time.Time, userIDs []string, text string, textUserIDs []string, + page, pageSize int) (rows []*entity.AdminMessage, total int64, err error) { + cond := builder.NewCond() + if !from.IsZero() { + cond = cond.And(builder.Gte{"created_at": from}) + } + if !to.IsZero() { + cond = cond.And(builder.Lt{"created_at": to}) + } + if len(userIDs) > 0 { + cond = cond.And(builder.Or(builder.In("sender_user_id", userIDs), builder.In("receiver_user_id", userIDs))) + } + if text != "" { + like := "%" + text + "%" + tc := builder.Or(builder.Like{"title", like}, builder.Like{"body", like}) + if len(textUserIDs) > 0 { + tc = tc.Or(builder.In("sender_user_id", textUserIDs), builder.In("receiver_user_id", textUserIDs)) + } + cond = cond.And(tc) + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } + rows = make([]*entity.AdminMessage, 0) + total, err = r.data.DB.Context(ctx).Where(cond).Desc("created_at").Desc("id").Limit(pageSize, (page-1)*pageSize).FindAndCount(&rows) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// SearchUserIDs users whose username / display name / e-mail contains text +func (r *adminMessageRepo) SearchUserIDs(ctx context.Context, text string, limit int) ([]string, error) { + like := "%" + text + "%" + users := make([]*entity.User, 0) + err := r.data.DB.Context(ctx).Cols("id"). + Where(builder.Or(builder.Like{"username", like}, builder.Like{"display_name", like}, builder.Like{"e_mail", like})). + Limit(limit).Find(&users) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + ids := make([]string, 0, len(users)) + for _, u := range users { + ids = append(ids, u.ID) + } + return ids, nil +} + +// EmailsByIDs id → e-mail (the admin list shows the receiver's address) +func (r *adminMessageRepo) EmailsByIDs(ctx context.Context, ids []string) (map[string]string, error) { + users := make([]*entity.User, 0) + if len(ids) == 0 { + return map[string]string{}, nil + } + if err := r.data.DB.Context(ctx).Cols("id", "e_mail").In("id", ids).Find(&users); err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + out := make(map[string]string, len(users)) + for _, u := range users { + out[u.ID] = u.EMail + } + return out, nil +} diff --git a/internal/repo/notification/notification_repo.go b/internal/repo/notification/notification_repo.go index 8d8e707f2..476eebfca 100644 --- a/internal/repo/notification/notification_repo.go +++ b/internal/repo/notification/notification_repo.go @@ -31,6 +31,7 @@ import ( notficationcommon "github.com/apache/answer/internal/service/notification_common" "github.com/apache/answer/pkg/uid" "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" ) // notificationRepo notification repository @@ -73,9 +74,24 @@ func (nr *notificationRepo) ClearUnRead(ctx context.Context, userID string, noti if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } + nr.markAdminMessagesRead(ctx, userID, "") return } +// markAdminMessagesRead stamps read_at on admin messages whose notification was just marked read +func (nr *notificationRepo) markAdminMessagesRead(ctx context.Context, userID, notificationID string) { + sql := "UPDATE admin_message SET read_at = ? WHERE read_at IS NULL AND receiver_user_id = ? AND id IN (SELECT object_id FROM notification WHERE user_id = ? AND msg_type = ?" + args := []any{time.Now(), userID, userID, schema.NotificationInboxTypeMessages} + if notificationID != "" { + sql += " AND id = ?" + args = append(args, notificationID) + } + sql += ")" + if _, err := nr.data.DB.Context(ctx).Exec(append([]any{sql}, args...)...); err != nil { + log.Warnf("mark admin messages read: %v", err) + } +} + func (nr *notificationRepo) ClearIDUnRead(ctx context.Context, userID string, id string) (err error) { info := &entity.Notification{} info.IsRead = schema.NotificationRead @@ -83,6 +99,7 @@ func (nr *notificationRepo) ClearIDUnRead(ctx context.Context, userID string, id if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } + nr.markAdminMessagesRead(ctx, userID, id) return } diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 510a94aaa..e219b3d4e 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -23,6 +23,7 @@ import ( "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/admin_message" "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" "github.com/apache/answer/internal/repo/api_key" @@ -110,6 +111,7 @@ var ProviderSetRepo = wire.NewSet( badge.NewEventRuleRepo, badge_group.NewBadgeGroupRepo, badge_award.NewBadgeAwardRepo, + admin_message.NewAdminMessageRepo, file_record.NewFileRecordRepo, api_key.NewAPIKeyRepo, ai_conversation.NewAIConversationRepo, diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 84b8b4e1c..f94127b50 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -62,6 +62,7 @@ type AnswerAPIRouter struct { aiConversationController *controller.AIConversationController aiConversationAdminController *controller_admin.AIConversationAdminController mcpController *controller.MCPController + adminMessageController *controller.AdminMessageController } func NewAnswerAPIRouter( @@ -100,6 +101,7 @@ func NewAnswerAPIRouter( aiConversationController *controller.AIConversationController, aiConversationAdminController *controller_admin.AIConversationAdminController, mcpController *controller.MCPController, + adminMessageController *controller.AdminMessageController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -137,6 +139,7 @@ func NewAnswerAPIRouter( aiConversationController: aiConversationController, aiConversationAdminController: aiConversationAdminController, mcpController: mcpController, + adminMessageController: adminMessageController, } } @@ -246,6 +249,9 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { // vote r.POST("/vote/up", a.voteController.VoteUp) + // messages from admins / moderators (role checked in the service) + r.POST("/admin-message", a.adminMessageController.Send) + r.GET("/admin-message/page", a.adminMessageController.Page) r.POST("/vote/down", a.voteController.VoteDown) // follow diff --git a/internal/schema/admin_message_schema.go b/internal/schema/admin_message_schema.go new file mode 100644 index 000000000..5605f9c57 --- /dev/null +++ b/internal/schema/admin_message_schema.go @@ -0,0 +1,68 @@ +/* + * 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 + +// SendAdminMessageReq admin / moderator writes to a user +type SendAdminMessageReq struct { + Username string `validate:"required,gt=0,lte=64" json:"username"` + Title string `validate:"required,notblank,gte=2,lte=200" json:"title"` + Body string `validate:"required,notblank,gte=2,lte=4000" json:"body"` + // ObjectID optional context: the question / answer / comment the message is about + ObjectID string `validate:"omitempty" json:"object_id"` + LoginUserID string `json:"-"` +} + +// AdminMessagePageReq list filters +type AdminMessagePageReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1,max=200" form:"page_size"` + From int64 `validate:"omitempty" form:"from"` + To int64 `validate:"omitempty" form:"to"` + Username string `validate:"omitempty,lte=64" form:"username"` + Q string `validate:"omitempty,lte=200" form:"q"` + LoginUserID string `json:"-"` +} + +// AdminMessageUser sender / receiver +type AdminMessageUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Email string `json:"email,omitempty"` + Status string `json:"status"` +} + +// AdminMessageItem one row of the admin list +type AdminMessageItem struct { + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + ReadAt int64 `json:"read_at"` // 0 = unread + Sender *AdminMessageUser `json:"sender"` + Receiver *AdminMessageUser `json:"receiver"` + Title string `json:"title"` + Body string `json:"body"` + ObjectType string `json:"object_type,omitempty"` + ObjectID string `json:"object_id,omitempty"` + QuestionID string `json:"question_id,omitempty"` + AnswerID string `json:"answer_id,omitempty"` + ObjectTitle string `json:"object_title,omitempty"` + UrlTitle string `json:"url_title,omitempty"` +} diff --git a/internal/schema/notification_schema.go b/internal/schema/notification_schema.go index b9e029389..61e823c38 100644 --- a/internal/schema/notification_schema.go +++ b/internal/schema/notification_schema.go @@ -27,16 +27,17 @@ import ( ) const ( - NotificationTypeInbox = 1 - NotificationTypeAchievement = 2 - NotificationNotRead = 1 - NotificationRead = 2 - NotificationStatusNormal = 1 - NotificationStatusDelete = 10 - NotificationInboxTypeAll = 0 - NotificationInboxTypePosts = 1 - NotificationInboxTypeVotes = 2 - NotificationInboxTypeInvites = 3 + NotificationTypeInbox = 1 + NotificationTypeAchievement = 2 + NotificationNotRead = 1 + NotificationRead = 2 + NotificationStatusNormal = 1 + NotificationStatusDelete = 10 + NotificationInboxTypeAll = 0 + NotificationInboxTypePosts = 1 + NotificationInboxTypeVotes = 2 + NotificationInboxTypeInvites = 3 + NotificationInboxTypeMessages = 4 // admin messages ) var NotificationType = map[string]int{ @@ -45,10 +46,11 @@ var NotificationType = map[string]int{ } var NotificationInboxType = map[string]int{ - "all": NotificationInboxTypeAll, - "posts": NotificationInboxTypePosts, - "invites": NotificationInboxTypeInvites, - "votes": NotificationInboxTypeVotes, + "all": NotificationInboxTypeAll, + "posts": NotificationInboxTypePosts, + "invites": NotificationInboxTypeInvites, + "messages": NotificationInboxTypeMessages, + "votes": NotificationInboxTypeVotes, } type NotificationContent struct { diff --git a/internal/service/admin_message/admin_message_service.go b/internal/service/admin_message/admin_message_service.go new file mode 100644 index 000000000..ae8115f6b --- /dev/null +++ b/internal/service/admin_message/admin_message_service.go @@ -0,0 +1,228 @@ +/* + * 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 admin_message + +import ( + "context" + "strings" + "time" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/noticequeue" + "github.com/apache/answer/internal/service/object_info" + "github.com/apache/answer/internal/service/role" + "github.com/apache/answer/internal/service/unique" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/apache/answer/pkg/htmltext" + "github.com/apache/answer/pkg/uid" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +// AdminMessageRepo persistence +type AdminMessageRepo interface { + Add(ctx context.Context, m *entity.AdminMessage) error + Page(ctx context.Context, from, to time.Time, userIDs []string, text string, textUserIDs []string, page, pageSize int) ([]*entity.AdminMessage, int64, error) + SearchUserIDs(ctx context.Context, text string, limit int) ([]string, error) + EmailsByIDs(ctx context.Context, ids []string) (map[string]string, error) +} + +// AdminMessageService messages from admins / moderators to users +type AdminMessageService struct { + repo AdminMessageRepo + uniqueIDRepo unique.UniqueIDRepo + userCommon *usercommon.UserCommon + userRoleService *role.UserRoleRelService + objectService *object_info.ObjService + notificationQueue noticequeue.Service +} + +// NewAdminMessageService new service +func NewAdminMessageService( + repo AdminMessageRepo, + uniqueIDRepo unique.UniqueIDRepo, + userCommon *usercommon.UserCommon, + userRoleService *role.UserRoleRelService, + objectService *object_info.ObjService, + notificationQueue noticequeue.Service, +) *AdminMessageService { + return &AdminMessageService{repo: repo, uniqueIDRepo: uniqueIDRepo, userCommon: userCommon, userRoleService: userRoleService, + objectService: objectService, notificationQueue: notificationQueue} +} + +// checkStaff only admins and moderators may send / list +func (s *AdminMessageService) checkStaff(ctx context.Context, userID string) error { + roleID, err := s.userRoleService.GetUserRole(ctx, userID) + if err != nil { + return err + } + if roleID != role.RoleAdminID && roleID != role.RoleModeratorID { + return errors.Forbidden(reason.ForbiddenError) + } + return nil +} + +// Send stores the message and delivers it as an inbox notification of the "messages" kind +func (s *AdminMessageService) Send(ctx context.Context, req *schema.SendAdminMessageReq) (*schema.AdminMessageItem, error) { + if err := s.checkStaff(ctx, req.LoginUserID); err != nil { + return nil, err + } + receiver, exist, err := s.userCommon.GetUserBasicInfoByUserName(ctx, strings.TrimSpace(req.Username)) + if err != nil { + return nil, err + } + if !exist { + return nil, errors.BadRequest(reason.UserNotFound) + } + id, err := s.uniqueIDRepo.GenUniqueIDStr(ctx, constant.AdminMessageObjectType) + if err != nil { + return nil, err + } + m := &entity.AdminMessage{ID: id, SenderUserID: req.LoginUserID, ReceiverUserID: receiver.ID, + Title: strings.TrimSpace(req.Title), Body: strings.TrimSpace(req.Body), ObjectID: "0", QuestionID: "0", AnswerID: "0"} + if req.ObjectID != "" { + if info, err := s.objectService.GetInfo(ctx, uid.DeShortID(req.ObjectID)); err == nil && info != nil { + m.ObjectType, m.ObjectID = info.ObjectType, info.ObjectID + if info.QuestionID != "" { + m.QuestionID = info.QuestionID + } + if info.AnswerID != "" { + m.AnswerID = info.AnswerID + } + } + } + if err = s.repo.Add(ctx, m); err != nil { + return nil, err + } + // the notification: object = the message itself, the body travels in the object map + s.notificationQueue.Send(ctx, &schema.NotificationMsg{ + TriggerUserID: req.LoginUserID, + ReceiverUserID: receiver.ID, + Type: schema.NotificationTypeInbox, + Title: m.Title, + ObjectID: m.ID, + ObjectType: constant.AdminMessageObjectType, + NotificationAction: constant.NotificationAdminMessage, + NoNeedPushAllFollow: true, + ExtraInfo: map[string]string{"message": m.ID, "body": m.Body, + "question": zeroToEmpty(m.QuestionID), "answer": zeroToEmpty(m.AnswerID), "context_type": m.ObjectType}, + }) + items := s.decorate(ctx, []*entity.AdminMessage{m}) + return items[0], nil +} + +// Page the admin list +func (s *AdminMessageService) Page(ctx context.Context, req *schema.AdminMessagePageReq) (*pager.PageModel, error) { + if err := s.checkStaff(ctx, req.LoginUserID); err != nil { + return nil, err + } + var from, to time.Time + if req.From > 0 { + from = time.Unix(req.From, 0) + } + if req.To > 0 { + to = time.Unix(req.To, 0) + } + var userIDs, textUserIDs []string + if u := strings.TrimSpace(req.Username); u != "" { + info, exist, err := s.userCommon.GetUserBasicInfoByUserName(ctx, u) + if err != nil { + return nil, err + } + if !exist { + userIDs = []string{"-1"} + } else { + userIDs = []string{info.ID} + } + } + text := strings.TrimSpace(req.Q) + if text != "" { + ids, err := s.repo.SearchUserIDs(ctx, text, 50) + if err != nil { + return nil, err + } + textUserIDs = ids + } + rows, total, err := s.repo.Page(ctx, from, to, userIDs, text, textUserIDs, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return pager.NewPageModel(total, s.decorate(ctx, rows)), nil +} + +func (s *AdminMessageService) decorate(ctx context.Context, rows []*entity.AdminMessage) []*schema.AdminMessageItem { + ids := make([]string, 0, len(rows)*2) + seen := map[string]bool{} + for _, r := range rows { + for _, id := range []string{r.SenderUserID, r.ReceiverUserID} { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + } + users := map[string]*schema.UserBasicInfo{} + if len(ids) > 0 { + if m, err := s.userCommon.BatchUserBasicInfoByID(ctx, ids); err == nil { + users = m + } else { + log.Error(err) + } + } + emails, err := s.repo.EmailsByIDs(ctx, ids) + if err != nil { + log.Error(err) + } + toUser := func(id string) *schema.AdminMessageUser { + u := users[id] + if u == nil { + return &schema.AdminMessageUser{ID: id, DisplayName: "#" + id} + } + return &schema.AdminMessageUser{ID: u.ID, Username: u.Username, DisplayName: u.DisplayName, Avatar: u.Avatar, Email: emails[id], Status: u.Status} + } + items := make([]*schema.AdminMessageItem, 0, len(rows)) + for _, r := range rows { + it := &schema.AdminMessageItem{ID: r.ID, CreatedAt: r.CreatedAt.Unix(), Sender: toUser(r.SenderUserID), Receiver: toUser(r.ReceiverUserID), + Title: r.Title, Body: r.Body, ObjectType: r.ObjectType, ObjectID: zeroToEmpty(r.ObjectID), + QuestionID: zeroToEmpty(r.QuestionID), AnswerID: zeroToEmpty(r.AnswerID)} + if r.ReadAt != nil { + it.ReadAt = r.ReadAt.Unix() + } + if it.ObjectID != "" && s.objectService != nil { + if info, err := s.objectService.GetInfo(ctx, it.ObjectID); err == nil && info != nil { + it.ObjectTitle = info.Title + it.UrlTitle = htmltext.UrlTitle(info.Title) + } + } + items = append(items, it) + } + return items +} + +func zeroToEmpty(id string) string { + if id == "0" { + return "" + } + return id +} diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index aa3f4106c..17c2139f8 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -128,6 +128,10 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N objectMap := make(map[string]string) objectMap["badge_id"] = msg.ExtraInfo["badge_id"] req.ObjectInfo.ObjectMap = objectMap + } else if msg.ObjectType == constant.AdminMessageObjectType { + // admin message: no post behind it, the message itself is the object + req.ObjectInfo.Title = msg.Title + req.ObjectInfo.ObjectMap = msg.ExtraInfo } else { objInfo, err = ns.objectInfoService.GetInfo(ctx, req.ObjectInfo.ObjectID) if err != nil { diff --git a/internal/service/provider.go b/internal/service/provider.go index d848272f7..32de4bea6 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -24,6 +24,7 @@ import ( "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" "github.com/apache/answer/internal/service/activityqueue" + "github.com/apache/answer/internal/service/admin_message" "github.com/apache/answer/internal/service/ai_conversation" answercommon "github.com/apache/answer/internal/service/answer_common" "github.com/apache/answer/internal/service/apikey" @@ -128,6 +129,7 @@ var ProviderSetService = wire.NewSet( meta.NewMetaService, eventqueue.NewService, badge.NewBadgeService, + admin_message.NewAdminMessageService, badge.NewBadgeEventService, badge.NewBadgeAwardService, badge.NewBadgeGroupService, diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 862072817..7933dca65 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -117,6 +117,7 @@ export const ADMIN_NAV_MENUS = [ children: [ { name: 'users', pathPrefix: 'users/' }, { name: 'badges' }, + { name: 'admin_messages', path: 'admin-messages' }, { name: 'rules', path: 'rules/privileges', pathPrefix: 'rules/' }, ], }, diff --git a/ui/src/components/Comment/components/ActionBar/index.tsx b/ui/src/components/Comment/components/ActionBar/index.tsx index 692b0d208..32de64d07 100644 --- a/ui/src/components/Comment/components/ActionBar/index.tsx +++ b/ui/src/components/Comment/components/ActionBar/index.tsx @@ -24,7 +24,7 @@ import { Link } from 'react-router-dom'; import classNames from 'classnames'; -import { Icon, FormatTime } from '@/components'; +import { Icon, FormatTime, MessageUserButton } from '@/components'; const ActionBar = ({ nickName, @@ -37,6 +37,8 @@ const ActionBar = ({ onVote, onAction, userStatus = '', + commentId = '', + objectTitle = '', }) => { const { t } = useTranslation('translation', { keyPrefix: 'comment' }); @@ -75,6 +77,13 @@ const ActionBar = ({ onClick={onReply}> {t('btn_reply')} +
{memberActions.map((action, index) => { diff --git a/ui/src/components/Comment/index.tsx b/ui/src/components/Comment/index.tsx index af2ddbd4f..3bffb38e6 100644 --- a/ui/src/components/Comment/index.tsx +++ b/ui/src/components/Comment/index.tsx @@ -456,6 +456,7 @@ const Comment: FC = ({ objectId, mode, commentId, children }) => { isVote={item.is_vote} memberActions={item.member_actions} userStatus={item.user_status} + commentId={item.comment_id} onReply={() => { handleReply(item.comment_id); }} diff --git a/ui/src/components/MessageUserButton/index.tsx b/ui/src/components/MessageUserButton/index.tsx new file mode 100644 index 000000000..cb4136ef7 --- /dev/null +++ b/ui/src/components/MessageUserButton/index.tsx @@ -0,0 +1,231 @@ +/* + * 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, useEffect, useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import useSWR from 'swr'; +import qs from 'qs'; + +import { Icon } from '@/components'; +import { useToast } from '@/hooks'; +import { sendAdminMessage } from '@/services'; +import request from '@/utils/request'; +import { loggedUserInfoStore } from '@/stores'; + +interface Props { + /** fixed recipient; when omitted the modal asks for a user */ + username?: string; + /** the question / answer / comment the message is about (shown as context, stored with the message) */ + objectId?: string; + objectTitle?: string; + variant?: 'icon' | 'button'; + className?: string; + onSent?: () => void; +} + +/** + * "Write a message" for admins and moderators: the recipient gets it in Notifications → Messages. + * Renders nothing for regular users. + */ +const MessageUserButton: FC = ({ + username, + objectId, + objectTitle, + variant = 'button', + className = '', + onSent, +}) => { + const { t } = useTranslation('translation', { keyPrefix: 'admin_message' }); + const roleId = loggedUserInfoStore((state) => state.user?.role_id); + const Toast = useToast(); + const [show, setShow] = useState(false); + const [user, setUser] = useState(username || ''); + const [query, setQuery] = useState(''); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [submitting, setSubmitting] = useState(false); + // user suggestions come from the admin user list, so only admins get them; moderators type the username + const { data: users } = useSWR<{ list: any[] }>( + show && !username && roleId === 2 && query.length >= 2 + ? `/answer/admin/api/users/page?${qs.stringify({ page: 1, page_size: 8, query })}` + : null, + request.instance.get, + ); + + useEffect(() => { + setUser(username || ''); + }, [username, show]); + + if (roleId !== 2 && roleId !== 3) { + return null; + } + + const handleSubmit = () => { + const to = (user || query).trim(); + if (!to || !title.trim() || !body.trim()) { + return; + } + setSubmitting(true); + sendAdminMessage({ + username: to, + title: title.trim(), + body: body.trim(), + object_id: objectId || undefined, + }) + .then(() => { + Toast.onShow({ + msg: t('success', { username: to }), + variant: 'success', + }); + setShow(false); + setTitle(''); + setBody(''); + onSent?.(); + }) + .catch((err) => { + Toast.onShow({ msg: err?.msg || t('failed'), variant: 'danger' }); + }) + .finally(() => setSubmitting(false)); + }; + + const open = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShow(true); + }; + + return ( + <> + {variant === 'icon' ? ( + + ) : ( + + )} + setShow(false)} + onClick={(e) => e.stopPropagation()}> + + {t('title')} + + +
+ + {t('to')} + {username ? ( + + ) : ( + <> + { + setQuery(e.target.value); + setUser(''); + }} + /> + {!user && users?.list?.length ? ( +
+ {users.list.map((u) => ( + + ))} +
+ ) : null} + + )} +
+ {objectTitle ? ( +
+ {t('context')}: {objectTitle} +
+ ) : null} + + {t('subject')} + setTitle(e.target.value)} + /> + + + {t('body')} + setBody(e.target.value)} + /> + +
+
+ + + + +
+ + ); +}; + +export default MessageUserButton; diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 5c81739bb..6793993e8 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -40,6 +40,7 @@ import Toast from './Toast'; import AccordionNav from './AccordionNav'; import Empty from './Empty'; import BaseUserCard from './BaseUserCard'; +import MessageUserButton from './MessageUserButton'; import FollowingTags from './FollowingTags'; import QueryGroup from './QueryGroup'; import BrandUpload from './BrandUpload'; @@ -93,6 +94,7 @@ export { AccordionNav, Empty, BaseUserCard, + MessageUserButton, FollowingTags, htmlRender, QueryGroup, diff --git a/ui/src/pages/Admin/AdminMessages/index.tsx b/ui/src/pages/Admin/AdminMessages/index.tsx new file mode 100644 index 000000000..973ec7a8e --- /dev/null +++ b/ui/src/pages/Admin/AdminMessages/index.tsx @@ -0,0 +1,281 @@ +/* + * 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, useEffect, useState } from 'react'; +import { Form, Table } from 'react-bootstrap'; +import { Link, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import dayjs from 'dayjs'; +import qs from 'qs'; + +import { + BaseUserCard, + Empty, + Pagination, + MessageUserButton, +} from '@/components'; +import request from '@/utils/request'; +import { useQueryAdminMessages, AdminMessageItem } from '@/services'; + +const PAGE_SIZE = 50; + +// who got a message from an admin / moderator, when, and when they read it +const Index: FC = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.admin_messages', + }); + const [urlSearchParams, setUrlSearchParams] = useSearchParams(); + const page = Number(urlSearchParams.get('page') || 1); + const username = urlSearchParams.get('username') || ''; + const from = urlSearchParams.get('from') || ''; + const to = urlSearchParams.get('to') || ''; + const q = urlSearchParams.get('q') || ''; + + const [qInput, setQInput] = useState(q); + const [userInput, setUserInput] = useState(username); + const [userOptions, setUserOptions] = useState< + { username: string; display_name: string }[] + >([]); + const [expanded, setExpanded] = useState(null); + + useEffect(() => setQInput(q), [q]); + useEffect(() => setUserInput(username), [username]); + + const { data, isLoading, mutate } = useQueryAdminMessages({ + page, + page_size: PAGE_SIZE, + from: from ? dayjs(from).startOf('day').unix() : undefined, + to: to ? dayjs(to).add(1, 'day').startOf('day').unix() : undefined, + username, + q, + }); + + const setParams = (patch: Record) => { + const next: Record = {}; + urlSearchParams.forEach((v, k) => { + next[k] = v; + }); + Object.entries(patch).forEach(([k, v]) => { + if (v === undefined || v === '') delete next[k]; + else next[k] = v; + }); + delete next.page; + setUrlSearchParams(next); + }; + + useEffect(() => { + const term = userInput.trim(); + if (!term || term === username) { + setUserOptions([]); + return undefined; + } + const timer = setTimeout(() => { + request + .get( + `/answer/admin/api/users/page?${qs.stringify({ page: 1, page_size: 8, query: term })}`, + ) + .then((res: any) => setUserOptions(res?.list || [])) + .catch(() => setUserOptions([])); + }, 300); + return () => clearTimeout(timer); + }, [userInput, username]); + + const contextLink = (m: AdminMessageItem) => { + if (!m.question_id) return ''; + const base = `/questions/${m.question_id}/${m.url_title || ''}`; + if (m.object_type === 'answer' && m.answer_id) + return `${base}/${m.answer_id}`; + if (m.object_type === 'comment') + return `${base}${m.answer_id ? `/${m.answer_id}` : ''}?commentId=${m.object_id}`; + return base; + }; + + return ( + <> +
+

{t('title')}

+ mutate()} /> +
+
+
+ + {t('filter.user')} + + setUserInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') setParams({ username: userInput.trim() }); + }} + onBlur={() => { + if (userInput.trim() !== username) + setParams({ username: userInput.trim() }); + }} + /> + + {userOptions.map((u) => ( + + ))} + +
+
+ + {t('filter.range')} + +
+ setParams({ from: e.target.value })} + style={{ width: '10rem' }} + /> + + setParams({ to: e.target.value })} + style={{ width: '10rem' }} + /> +
+
+
+ + {t('filter.search')} + + setQInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') setParams({ q: qInput.trim() }); + }} + onBlur={() => { + if (qInput.trim() !== q) setParams({ q: qInput.trim() }); + }} + /> +
+
+ +
+ {t('total', { count: Number(data?.count || 0) })} +
+ + + + + + + + + + + + + {data?.list?.map((m) => { + const link = contextLink(m); + const open = expanded === m.id; + return ( + + + + + + + + ); + })} + +
{t('col.sent')}{t('col.from')}{t('col.to')}{t('col.message')}{t('col.read')}
+ {dayjs.unix(m.created_at).format('YYYY-MM-DD HH:mm')} + + + + + {m.receiver?.email ? ( +
+ {m.receiver.email} +
+ ) : null} +
+
{m.title}
+
setExpanded(open ? null : m.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') setExpanded(open ? null : m.id); + }}> + {open || m.body.length <= 160 + ? m.body + : `${m.body.slice(0, 160)}…`} +
+ {link ? ( +
+ + {t('context')}: {m.object_title || m.object_type} + +
+ ) : null} +
+ {m.read_at ? ( + + {dayjs.unix(m.read_at).format('YYYY-MM-DD HH:mm')} + + ) : ( + {t('unread')} + )} +
+ {Number(data?.count) <= 0 && !isLoading && } +
+ +
+ + ); +}; + +export default Index; diff --git a/ui/src/pages/Questions/Detail/components/Answer/index.tsx b/ui/src/pages/Questions/Detail/components/Answer/index.tsx index bd0ecbb5b..e410324d2 100644 --- a/ui/src/pages/Questions/Detail/components/Answer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Answer/index.tsx @@ -30,6 +30,7 @@ import { Comment, htmlRender, ImgViewer, + MessageUserButton, } from '@/components'; import { scrollToElementTop, bgFadeOut } from '@/utils'; import { AnswerItem } from '@/common/interface'; @@ -119,6 +120,13 @@ const Index: FC = ({ isLogged={isLogged} timelinePath={`/posts/${data.question_id}/${data.id}/timeline`} /> +
{data?.accepted === 2 && ( diff --git a/ui/src/pages/Questions/Detail/components/Question/index.tsx b/ui/src/pages/Questions/Detail/components/Question/index.tsx index f13fa31df..0e1a81fc7 100644 --- a/ui/src/pages/Questions/Detail/components/Question/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Question/index.tsx @@ -27,6 +27,7 @@ import { Actions, Operate, BaseUserCard, + MessageUserButton, Comment, FormatTime, htmlRender, @@ -104,6 +105,13 @@ const Index: FC = ({ data, initPage, hasAnswer, isLogged }) => {
+ {isLogged ? ( <> diff --git a/ui/src/pages/Users/Notifications/components/Inbox/index.tsx b/ui/src/pages/Users/Notifications/components/Inbox/index.tsx index 64a2c34ba..a664d29b5 100644 --- a/ui/src/pages/Users/Notifications/components/Inbox/index.tsx +++ b/ui/src/pages/Users/Notifications/components/Inbox/index.tsx @@ -53,6 +53,45 @@ const Inbox = ({ data, handleReadNotification }) => { default: url = ''; } + // a message from an admin / moderator: title + body inline, no post behind it + if (item.object_info.object_type === 'admin_message') { + const map = item.object_info.object_map || {}; + const contextUrl = map.question + ? `/questions/${map.question}${map.answer ? `/${map.answer}` : ''}` + : ''; + return ( + !item.is_read && handleReadNotification(item.id)}> +
+ {item.user_info && item.user_info.status !== 'deleted' ? ( + + {item.user_info.display_name}{' '} + + ) : ( + {item.user_info?.display_name || t('someone')} + )} + {item.notification_action}{' '} + {item.object_info.title} +
+
+ {map.body} +
+ {contextUrl ? ( +
+ {t('message_context')} +
+ ) : null} +
+ +
+
+ ); + } return ( { const [page, setPage] = useState(1); const [notificationData, setNotificationData] = useState([]); const { t } = useTranslation('translation', { keyPrefix: 'notifications' }); - const inboxTypeNavs = ['all', 'posts', 'invites', 'votes']; + const inboxTypeNavs = ['all', 'posts', 'invites', 'votes', 'messages']; const { type = 'inbox', subType = inboxTypeNavs[0] } = useParams(); const queryParams: { diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 8423fb7a3..422433e60 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -397,6 +397,10 @@ const routes: RouteNode[] = [ path: 'users', page: 'pages/Admin/Users', }, + { + path: 'admin-messages', + page: 'pages/Admin/AdminMessages', + }, { path: 'users/settings', page: 'pages/Admin/UsersSettings', diff --git a/ui/src/services/client/adminMessage.ts b/ui/src/services/client/adminMessage.ts new file mode 100644 index 000000000..ba839ec1a --- /dev/null +++ b/ui/src/services/client/adminMessage.ts @@ -0,0 +1,80 @@ +/* + * 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 qs from 'qs'; +import useSWR from 'swr'; + +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; + +// messages from admins / moderators to users +export interface AdminMessageUser { + id: string; + username: string; + display_name: string; + avatar?: string; + email?: string; + status?: string; +} + +export interface AdminMessageItem { + id: string; + created_at: number; + read_at: number; + sender: AdminMessageUser; + receiver: AdminMessageUser; + title: string; + body: string; + object_type?: string; + object_id?: string; + question_id?: string; + answer_id?: string; + object_title?: string; + url_title?: string; +} + +export interface AdminMessagePageParams { + page?: number; + page_size?: number; + from?: number; + to?: number; + username?: string; + q?: string; +} + +export const sendAdminMessage = (params: { + username: string; + title: string; + body: string; + object_id?: string; +}) => { + return request.post('/answer/api/v1/admin-message', params); +}; + +export const useQueryAdminMessages = (params: AdminMessagePageParams) => { + const apiUrl = `/answer/api/v1/admin-message/page?${qs.stringify(params, { + skipNulls: true, + filter: (_, v) => (v === '' ? undefined : v), + })}`; + const { data, error, mutate } = useSWR< + Type.ListResult, + Error + >(apiUrl, request.instance.get); + return { data, isLoading: !data && !error, error, mutate }; +}; diff --git a/ui/src/services/client/index.ts b/ui/src/services/client/index.ts index 4d5c85c10..4881955cf 100644 --- a/ui/src/services/client/index.ts +++ b/ui/src/services/client/index.ts @@ -20,6 +20,7 @@ export * from './activity'; export * from './personal'; export * from './notification'; +export * from './adminMessage'; export * from './question'; export * from './search'; export * from './tag';