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')}
+
| {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}
+
+ ) : 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')} + )} + | +