feat(search): align message result payloads

This commit is contained in:
2026-06-07 09:13:03 +08:00
parent e99330a06f
commit 626a1f7a29
9 changed files with 388 additions and 68 deletions
+149 -54
View File
@@ -1,10 +1,12 @@
package v1
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
@@ -17,11 +19,16 @@ const chatwootSearchPerPage = 15
// Reference: Chatwoot GlobalSearchService — cross-entity search with advanced filtering.
type SearchHandler struct {
svc *search.SearchService
db *gorm.DB
}
// NewSearchHandler creates a new SearchHandler.
func NewSearchHandler(svc *search.SearchService) *SearchHandler {
return &SearchHandler{svc: svc}
func NewSearchHandler(svc *search.SearchService, dbs ...*gorm.DB) *SearchHandler {
h := &SearchHandler{svc: svc}
if len(dbs) > 0 {
h.db = dbs[0]
}
return h
}
// GlobalSearch performs a unified search across conversations, messages, and contacts.
@@ -79,7 +86,7 @@ func (h *SearchHandler) GlobalSearch(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"payload": serializeSearchPayload(result.Results)})
c.JSON(http.StatusOK, gin.H{"payload": serializeSearchPayload(c.Request.Context(), h.db, result.Results)})
}
// SearchConversations performs a conversation-only search with advanced filters.
@@ -131,7 +138,7 @@ func (h *SearchHandler) SearchConversations(c *gin.Context) {
}
_ = total
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversations": serializeSearchConversations(results)}})
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversations": serializeSearchConversations(c.Request.Context(), h.db, results)}})
}
// SearchMessages performs a message-only search with advanced filters.
@@ -182,7 +189,7 @@ func (h *SearchHandler) SearchMessages(c *gin.Context) {
}
_ = total
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"messages": serializeSearchMessages(results)}})
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"messages": serializeSearchMessages(c.Request.Context(), h.db, results)}})
}
// SearchContacts performs a contact-only search with advanced filters.
@@ -278,11 +285,11 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"articles": serializeSearchArticles(results)}})
}
func serializeSearchPayload(results []search.SearchResult) gin.H {
func serializeSearchPayload(ctx context.Context, db *gorm.DB, results []search.SearchResult) gin.H {
return gin.H{
"conversations": serializeSearchConversations(filterSearchResults(results, search.ResultTypeConversation)),
"conversations": serializeSearchConversations(ctx, db, filterSearchResults(results, search.ResultTypeConversation)),
"contacts": serializeSearchContacts(filterSearchResults(results, search.ResultTypeContact)),
"messages": serializeSearchMessages(filterSearchResults(results, search.ResultTypeMessage)),
"messages": serializeSearchMessages(ctx, db, filterSearchResults(results, search.ResultTypeMessage)),
"articles": serializeSearchArticles(filterSearchResults(results, search.ResultTypeArticle)),
}
}
@@ -308,20 +315,20 @@ func filterSearchResults(results []search.SearchResult, resultType search.Search
return filtered
}
func serializeSearchConversations(results []search.SearchResult) []map[string]any {
func serializeSearchConversations(ctx context.Context, db *gorm.DB, results []search.SearchResult) []map[string]any {
payload := make([]map[string]any, 0, len(results))
for _, result := range results {
payload = append(payload, serializeSearchConversation(result))
payload = append(payload, serializeSearchConversation(ctx, db, result))
}
return payload
}
func serializeSearchConversation(result search.SearchResult) map[string]any {
func serializeSearchConversation(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any {
if conv, ok := result.Data.(model.Conversation); ok {
return serializeSearchConversationModel(&conv)
return serializeSearchConversationModel(ctx, db, &conv)
}
if conv, ok := result.Data.(*model.Conversation); ok && conv != nil {
return serializeSearchConversationModel(conv)
return serializeSearchConversationModel(ctx, db, conv)
}
root := searchDataRoot(result)
data := nestedSearchDataFromRoot(root, "conversation")
@@ -330,31 +337,31 @@ func serializeSearchConversation(result search.SearchResult) map[string]any {
"account_id": firstMapValue(data, "account_id"),
"created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")),
"additional_attributes": firstMapValue(data, "additional_attributes"),
"message": serializeSearchConversationMessageMap(firstNestedSearchData(root, data, "message")),
"message": serializeSearchConversationMessageMap(ctx, db, firstNestedSearchData(root, data, "message")),
"contact": serializeSearchConversationContactMap(firstNestedSearchData(root, data, "contact")),
"inbox": serializeSearchConversationInboxMap(firstNestedSearchData(root, data, "inbox")),
"agent": serializeSearchConversationAgentMap(firstNestedSearchData(root, data, "agent", "assignee")),
}
}
func serializeSearchConversationModel(conv *model.Conversation) map[string]any {
func serializeSearchConversationModel(ctx context.Context, db *gorm.DB, conv *model.Conversation) map[string]any {
return map[string]any{
"id": conversationDisplayID(conv),
"account_id": conv.AccountID,
"created_at": conv.CreatedAt.Unix(),
"additional_attributes": jsonObject(conv.AdditionalAttributes),
"message": serializeSearchConversationMessageModel(conv),
"message": serializeSearchConversationMessageModel(ctx, db, conv),
"contact": serializeSearchConversationContactModel(conv),
"inbox": serializeSearchConversationInboxModel(conv),
"agent": serializeSearchConversationAgentModel(conv),
}
}
func serializeSearchConversationMessageModel(conv *model.Conversation) map[string]any {
func serializeSearchConversationMessageModel(ctx context.Context, db *gorm.DB, conv *model.Conversation) map[string]any {
if len(conv.Messages) == 0 {
return map[string]any{}
}
return serializeSearchMessageModel(&conv.Messages[0])
return serializeSearchMessageModel(ctx, db, &conv.Messages[0])
}
func serializeSearchConversationContactModel(conv *model.Conversation) map[string]any {
@@ -395,11 +402,11 @@ func serializeSearchConversationAgentModel(conv *model.Conversation) map[string]
return map[string]any{}
}
func serializeSearchConversationMessageMap(data map[string]any) map[string]any {
func serializeSearchConversationMessageMap(ctx context.Context, db *gorm.DB, data map[string]any) map[string]any {
if len(data) == 0 {
return map[string]any{}
}
return serializeSearchMessage(search.SearchResult{Data: map[string]any{"message": data}})
return serializeSearchMessage(ctx, db, search.SearchResult{Data: map[string]any{"message": data}})
}
func serializeSearchConversationContactMap(data map[string]any) map[string]any {
@@ -481,37 +488,44 @@ func serializeSearchContactModel(contact *model.Contact) map[string]any {
}
}
func serializeSearchMessages(results []search.SearchResult) []map[string]any {
func serializeSearchMessages(ctx context.Context, db *gorm.DB, results []search.SearchResult) []map[string]any {
payload := make([]map[string]any, 0, len(results))
for _, result := range results {
payload = append(payload, serializeSearchMessage(result))
payload = append(payload, serializeSearchMessage(ctx, db, result))
}
return payload
}
func serializeSearchMessage(result search.SearchResult) map[string]any {
func serializeSearchMessage(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any {
if message, ok := result.Data.(model.Message); ok {
return serializeSearchMessageModel(&message)
return serializeSearchMessageModel(ctx, db, &message)
}
if message, ok := result.Data.(*model.Message); ok && message != nil {
return serializeSearchMessageModel(message)
return serializeSearchMessageModel(ctx, db, message)
}
data := nestedSearchData(result, "message")
return map[string]any{
"id": firstMapValue(data, "id"),
"content": firstMapValue(data, "content"),
"account_id": firstMapValue(data, "account_id"),
"inbox_id": firstMapValue(data, "inbox_id"),
"conversation_id": firstMapValue(data, "conversation_id"),
"message_type": normalizeSearchMessageType(firstMapValue(data, "message_type")),
"content_type": firstMapValue(data, "content_type"),
"status": firstMapValue(data, "status"),
"content_attributes": firstMapValue(data, "content_attributes"),
"additional_attributes": firstMapValue(data, "additional_attributes"),
"created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")),
"private": firstMapValue(data, "private"),
"source_id": firstMapValue(data, "source_id"),
if db != nil {
if payload, ok := loadSearchMessagePayload(ctx, db, uintFromAny(firstMapValue(data, "id")), result.AccountID); ok {
return payload
}
}
return omitNilSearchMessageFields(map[string]any{
"id": firstMapValue(data, "id"),
"content": firstMapValue(data, "content"),
"account_id": firstMapValue(data, "account_id"),
"inbox_id": firstMapValue(data, "inbox_id"),
"conversation_id": firstMapValue(data, "conversation_id"),
"message_type": normalizeSearchMessageType(firstMapValue(data, "message_type")),
"content_type": firstMapValue(data, "content_type"),
"status": firstMapValue(data, "status"),
"content_attributes": firstMapValue(data, "content_attributes"),
"created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")),
"private": firstMapValue(data, "private"),
"source_id": firstMapValue(data, "source_id"),
"echo_id": firstMapValue(data, "echo_id"),
"sender": firstMapValue(data, "sender"),
"attachments": firstMapValue(data, "attachments"),
})
}
func normalizeSearchMessageType(value any) any {
@@ -525,22 +539,83 @@ func normalizeSearchMessageType(value any) any {
return messageTypeValue(s)
}
func serializeSearchMessageModel(message *model.Message) map[string]any {
return map[string]any{
"id": message.ID,
"content": message.Content,
"account_id": message.AccountID,
"inbox_id": message.InboxID,
"conversation_id": message.ConversationID,
"message_type": messageTypeValue(message.MessageType),
"content_type": nonEmpty(message.ContentType, "text"),
"status": nonEmpty(message.Status, "sent"),
"content_attributes": jsonObject(message.ContentAttributes),
"additional_attributes": jsonObject(message.AdditionalAttributes),
"created_at": message.CreatedAt.Unix(),
"private": message.Private,
"source_id": message.SourceID,
func serializeSearchMessageModel(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any {
if db != nil {
if payload, ok := loadSearchMessagePayload(ctx, db, message.ID, message.AccountID); ok {
return payload
}
}
return omitNilSearchMessageFields(map[string]any{
"id": message.ID,
"content": message.Content,
"account_id": message.AccountID,
"inbox_id": message.InboxID,
"conversation_id": message.ConversationID,
"message_type": messageTypeValue(message.MessageType),
"content_type": nonEmpty(message.ContentType, "text"),
"status": nonEmpty(message.Status, "sent"),
"content_attributes": jsonObject(message.ContentAttributes),
"created_at": message.CreatedAt.Unix(),
"private": message.Private,
"source_id": message.SourceID,
"echo_id": message.EchoID,
})
}
func loadSearchMessagePayload(ctx context.Context, db *gorm.DB, messageID uint, accountID uint) (map[string]any, bool) {
if db == nil || messageID == 0 {
return nil, false
}
var message model.Message
q := db.WithContext(ctx).Where("id = ?", messageID)
if accountID != 0 {
q = q.Where("account_id = ?", accountID)
}
if err := q.First(&message).Error; err != nil {
return nil, false
}
var conversation model.Conversation
if err := db.WithContext(ctx).
Where("id = ? AND account_id = ?", message.ConversationID, message.AccountID).
First(&conversation).Error; err != nil {
return chatwootMessagePayloadMap(serializeMessage(ctx, db, &message, nil)), true
}
return chatwootMessagePayloadMap(serializeMessage(ctx, db, &message, &conversation)), true
}
func chatwootMessagePayloadMap(payload chatwootMessagePayload) map[string]any {
data := map[string]any{
"id": payload.ID,
"content": payload.Content,
"inbox_id": payload.InboxID,
"conversation_id": payload.ConversationID,
"message_type": payload.MessageType,
"content_type": payload.ContentType,
"status": payload.Status,
"content_attributes": payload.ContentAttributes,
"created_at": payload.CreatedAt,
"private": payload.Private,
"source_id": payload.SourceID,
}
if payload.EchoID != "" {
data["echo_id"] = payload.EchoID
}
if len(payload.Sender) > 0 {
data["sender"] = payload.Sender
}
if len(payload.Attachments) > 0 {
data["attachments"] = payload.Attachments
}
return data
}
func omitNilSearchMessageFields(data map[string]any) map[string]any {
for _, key := range []string{"echo_id", "sender", "attachments"} {
if data[key] == nil || data[key] == "" {
delete(data, key)
}
}
return data
}
func serializeSearchArticles(results []search.SearchResult) []map[string]any {
@@ -670,3 +745,23 @@ func unixFromMapValue(value any) any {
return typed
}
}
func uintFromAny(value any) uint {
switch typed := value.(type) {
case uint:
return typed
case int:
if typed > 0 {
return uint(typed)
}
case int64:
if typed > 0 {
return uint(typed)
}
case float64:
if typed > 0 {
return uint(typed)
}
}
return 0
}
+68 -1
View File
@@ -12,6 +12,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
@@ -342,9 +345,11 @@ func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) {
"id": float64(9),
"content": "hello from meili",
"account_id": float64(1),
"conversation_id": float64(3),
"conversation_id": float64(42),
"message_type": "incoming",
"created_at_ts": float64(1700000000),
"sender": map[string]any{"id": float64(7), "name": "Ada Contact"},
"attachments": []any{map[string]any{"id": float64(11), "file_type": "file", "data_url": "https://files.example/doc.pdf"}},
},
},
},
@@ -368,6 +373,68 @@ func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) {
message := messages[0].(map[string]any)
assert.Equal(t, float64(0), message["message_type"])
assert.Equal(t, float64(1700000000), message["created_at"])
assert.Equal(t, float64(42), message["conversation_id"])
assert.Equal(t, "Ada Contact", message["sender"].(map[string]any)["name"])
attachments := message["attachments"].([]any)
require.Len(t, attachments, 1)
assert.Equal(t, "https://files.example/doc.pdf", attachments[0].(map[string]any)["data_url"])
}
func TestSearchHandler_SearchMessages_HydratesChatwootMessagePayload(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:search-message-payload?mode=memory&cache=private"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}))
account := model.Account{Name: "Acme"}
require.NoError(t, db.Create(&account).Error)
inbox := model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget"}
require.NoError(t, db.Create(&inbox).Error)
contact := model.Contact{AccountID: account.ID, Name: "Ada Contact", Email: "ada@example.com"}
require.NoError(t, db.Create(&contact).Error)
agent := model.User{Name: "Agent One", Email: "agent@example.com"}
require.NoError(t, db.Create(&agent).Error)
displayID := uint(42)
conversation := model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open"}
require.NoError(t, db.Create(&conversation).Error)
message := model.Message{
AccountID: account.ID,
InboxID: inbox.ID,
ConversationID: conversation.ID,
SenderID: &agent.ID,
SenderType: "user",
MessageType: "outgoing",
ContentType: "text",
Status: "sent",
Content: "hello with attachment",
ContentAttributes: datatypes.JSON([]byte(`{"source":"search"}`)),
}
require.NoError(t, db.Create(&message).Error)
require.NoError(t, db.Create(&model.Attachment{AccountID: account.ID, MessageID: message.ID, FileType: "file", FileName: "quote.pdf", FileURL: "https://files.example/quote.pdf", FileSize: 128}).Error)
repo := &mockSearchRepo{messages: []model.Message{{Base: model.Base{ID: message.ID}, AccountID: account.ID}}, msgTotal: 1}
svc := search.NewSearchService(repo)
handler := NewSearchHandler(svc, db)
router := setupSearchHandlerRouter(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
payload := body["payload"].(map[string]any)
messages := payload["messages"].([]any)
require.Len(t, messages, 1)
got := messages[0].(map[string]any)
assert.Equal(t, float64(displayID), got["conversation_id"])
sender := got["sender"].(map[string]any)
assert.Equal(t, "Agent One", sender["name"])
attachments := got["attachments"].([]any)
require.Len(t, attachments, 1)
attachment := attachments[0].(map[string]any)
assert.Equal(t, "file", attachment["file_type"])
assert.Equal(t, "https://files.example/quote.pdf", attachment["data_url"])
}
func TestSearchHandler_SearchMessages_InvalidAccountID(t *testing.T) {