feat(search): align message result payloads
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user