From a16c23c7311b27ae89592500acd2af6bd0dbb903 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 06:38:08 +0800 Subject: [PATCH] feat(search): align chatwoot search payloads --- internal/handler/api/v1/search_handler.go | 307 ++++++++++++++++-- .../handler/api/v1/search_handler_test.go | 136 ++++++-- internal/repository/search_repo.go | 5 + internal/search/engine.go | 2 + internal/search/engine_meili.go | 5 +- internal/search/engine_test.go | 18 + internal/search/search_filter.go | 91 ++++-- internal/search/search_filter_test.go | 26 +- internal/search/search_service.go | 2 +- 9 files changed, 517 insertions(+), 75 deletions(-) diff --git a/internal/handler/api/v1/search_handler.go b/internal/handler/api/v1/search_handler.go index a56b115a..2fc0fc54 100644 --- a/internal/handler/api/v1/search_handler.go +++ b/internal/handler/api/v1/search_handler.go @@ -2,13 +2,17 @@ package v1 import ( "net/http" + "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/pkg/response" ) +const chatwootSearchPerPage = 15 + // SearchHandler handles global search API endpoints. // Reference: Chatwoot GlobalSearchService — cross-entity search with advanced filtering. type SearchHandler struct { @@ -67,7 +71,7 @@ func (h *SearchHandler) GlobalSearch(c *gin.Context) { } query := c.Query("q") - filter := search.ParseSearchFilter(c) + filter := parseChatwootSearchFilter(c) result, svcErr := h.svc.GlobalSearch(c.Request.Context(), accountID, query, &filter) if svcErr != nil { @@ -75,7 +79,7 @@ func (h *SearchHandler) GlobalSearch(c *gin.Context) { return } - response.OKWithMeta(c, result, result.Page, result.PerPage, result.TotalCount) + c.JSON(http.StatusOK, gin.H{"payload": serializeSearchPayload(result.Results)}) } // SearchConversations performs a conversation-only search with advanced filters. @@ -116,7 +120,7 @@ func (h *SearchHandler) SearchConversations(c *gin.Context) { } query := c.Query("q") - filter := search.ParseSearchFilter(c) + filter := parseChatwootSearchFilter(c) // Force type to conversations only filter.Types = []search.SearchResultType{search.ResultTypeConversation} @@ -126,10 +130,8 @@ func (h *SearchHandler) SearchConversations(c *gin.Context) { return } - response.OKWithMeta(c, gin.H{ - "results": results, - "by_type": gin.H{"conversation": total}, - }, filter.Page, filter.PerPage, total) + _ = total + c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversations": serializeSearchConversations(results)}}) } // SearchMessages performs a message-only search with advanced filters. @@ -169,7 +171,7 @@ func (h *SearchHandler) SearchMessages(c *gin.Context) { } query := c.Query("q") - filter := search.ParseSearchFilter(c) + filter := parseChatwootSearchFilter(c) // Force type to messages only filter.Types = []search.SearchResultType{search.ResultTypeMessage} @@ -179,10 +181,8 @@ func (h *SearchHandler) SearchMessages(c *gin.Context) { return } - response.OKWithMeta(c, gin.H{ - "results": results, - "by_type": gin.H{"message": total}, - }, filter.Page, filter.PerPage, total) + _ = total + c.JSON(http.StatusOK, gin.H{"payload": gin.H{"messages": serializeSearchMessages(results)}}) } // SearchContacts performs a contact-only search with advanced filters. @@ -216,7 +216,7 @@ func (h *SearchHandler) SearchContacts(c *gin.Context) { } query := c.Query("q") - filter := search.ParseSearchFilter(c) + filter := parseChatwootSearchFilter(c) // Force type to contacts only filter.Types = []search.SearchResultType{search.ResultTypeContact} @@ -226,10 +226,8 @@ func (h *SearchHandler) SearchContacts(c *gin.Context) { return } - response.OKWithMeta(c, gin.H{ - "results": results, - "by_type": gin.H{"contact": total}, - }, filter.Page, filter.PerPage, total) + _ = total + c.JSON(http.StatusOK, gin.H{"payload": gin.H{"contacts": serializeSearchContacts(results)}}) } // SearchArticles performs a knowledge base article-only search with advanced filters. @@ -266,7 +264,7 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) { } query := c.Query("q") - filter := search.ParseSearchFilter(c) + filter := parseChatwootSearchFilter(c) // Force type to articles only filter.Types = []search.SearchResultType{search.ResultTypeArticle} @@ -276,8 +274,273 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) { return } - response.OKWithMeta(c, gin.H{ - "results": results, - "by_type": gin.H{"article": total}, - }, filter.Page, filter.PerPage, total) + _ = total + c.JSON(http.StatusOK, gin.H{"payload": gin.H{"articles": serializeSearchArticles(results)}}) +} + +func serializeSearchPayload(results []search.SearchResult) gin.H { + return gin.H{ + "conversations": serializeSearchConversations(filterSearchResults(results, search.ResultTypeConversation)), + "contacts": serializeSearchContacts(filterSearchResults(results, search.ResultTypeContact)), + "messages": serializeSearchMessages(filterSearchResults(results, search.ResultTypeMessage)), + "articles": serializeSearchArticles(filterSearchResults(results, search.ResultTypeArticle)), + } +} + +func parseChatwootSearchFilter(c *gin.Context) search.SearchFilter { + filter := search.ParseSearchFilter(c) + if c.Query("per_page") == "" { + filter.PerPage = chatwootSearchPerPage + } + return filter +} + +func filterSearchResults(results []search.SearchResult, resultType search.SearchResultType) []search.SearchResult { + filtered := make([]search.SearchResult, 0) + for _, result := range results { + if result.Type == resultType { + filtered = append(filtered, result) + } + } + return filtered +} + +func serializeSearchConversations(results []search.SearchResult) []map[string]any { + payload := make([]map[string]any, 0, len(results)) + for _, result := range results { + payload = append(payload, serializeSearchConversation(result)) + } + return payload +} + +func serializeSearchConversation(result search.SearchResult) map[string]any { + if conv, ok := result.Data.(model.Conversation); ok { + return map[string]any{ + "id": conversationDisplayID(&conv), + "account_id": conv.AccountID, + "created_at": conv.CreatedAt.Unix(), + "additional_attributes": jsonObject(conv.AdditionalAttributes), + } + } + if conv, ok := result.Data.(*model.Conversation); ok && conv != nil { + return map[string]any{ + "id": conversationDisplayID(conv), + "account_id": conv.AccountID, + "created_at": conv.CreatedAt.Unix(), + "additional_attributes": jsonObject(conv.AdditionalAttributes), + } + } + data := nestedSearchData(result, "conversation") + return map[string]any{ + "id": firstMapValue(data, "display_id", "id"), + "account_id": firstMapValue(data, "account_id"), + "created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")), + "additional_attributes": firstMapValue(data, "additional_attributes"), + } +} + +func serializeSearchContacts(results []search.SearchResult) []map[string]any { + payload := make([]map[string]any, 0, len(results)) + for _, result := range results { + payload = append(payload, serializeSearchContact(result)) + } + return payload +} + +func serializeSearchContact(result search.SearchResult) map[string]any { + if contact, ok := result.Data.(model.Contact); ok { + return serializeSearchContactModel(&contact) + } + if contact, ok := result.Data.(*model.Contact); ok && contact != nil { + return serializeSearchContactModel(contact) + } + data := nestedSearchData(result, "contact") + return map[string]any{ + "email": firstMapValue(data, "email"), + "id": firstMapValue(data, "id"), + "name": firstMapValue(data, "name"), + "phone_number": firstMapValue(data, "phone_number"), + "identifier": firstMapValue(data, "identifier"), + "additional_attributes": firstMapValue(data, "additional_attributes"), + "last_activity_at": unixFromMapValue(firstMapValue(data, "last_activity_at")), + } +} + +func serializeSearchContactModel(contact *model.Contact) map[string]any { + return map[string]any{ + "email": contact.Email, + "id": contact.ID, + "name": contact.Name, + "phone_number": contact.PhoneNumber, + "identifier": contact.Identifier, + "additional_attributes": jsonObject(contact.AdditionalAttributes), + "last_activity_at": int64Value(contact.LastActivityAt), + } +} + +func serializeSearchMessages(results []search.SearchResult) []map[string]any { + payload := make([]map[string]any, 0, len(results)) + for _, result := range results { + payload = append(payload, serializeSearchMessage(result)) + } + return payload +} + +func serializeSearchMessage(result search.SearchResult) map[string]any { + if message, ok := result.Data.(model.Message); ok { + return serializeSearchMessageModel(&message) + } + if message, ok := result.Data.(*model.Message); ok && message != nil { + return serializeSearchMessageModel(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"), + } +} + +func normalizeSearchMessageType(value any) any { + s, ok := value.(string) + if !ok { + return value + } + if strings.TrimSpace(s) == "" { + return value + } + 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 serializeSearchArticles(results []search.SearchResult) []map[string]any { + payload := make([]map[string]any, 0, len(results)) + for _, result := range results { + payload = append(payload, serializeSearchArticle(result)) + } + return payload +} + +func serializeSearchArticle(result search.SearchResult) map[string]any { + if article, ok := result.Data.(model.Article); ok { + return serializeSearchArticleModel(&article) + } + if article, ok := result.Data.(*model.Article); ok && article != nil { + return serializeSearchArticleModel(article) + } + data := nestedSearchData(result, "article") + return map[string]any{ + "id": firstMapValue(data, "id"), + "title": firstMapValue(data, "title"), + "locale": firstMapValue(data, "locale"), + "content": firstMapValue(data, "content"), + "slug": firstMapValue(data, "slug"), + "portal_slug": firstMapValue(data, "portal_slug"), + "account_id": firstMapValue(data, "account_id"), + "category_name": firstMapValue(data, "category_name"), + "status": firstMapValue(data, "status"), + "updated_at": unixFromMapValue(firstMapValue(data, "updated_at", "updated_at_ts")), + } +} + +func serializeSearchArticleModel(article *model.Article) map[string]any { + portalSlug := "" + if article.Portal.Slug != "" { + portalSlug = article.Portal.Slug + } + categoryName := "" + if article.Category != nil && article.Category.Name != "" { + categoryName = article.Category.Name + } + return map[string]any{ + "id": article.ID, + "title": article.Title, + "locale": article.Locale, + "content": article.Content, + "slug": article.Slug, + "portal_slug": portalSlug, + "account_id": article.AccountID, + "category_name": categoryName, + "status": article.Status, + "updated_at": article.UpdatedAt.Unix(), + } +} + +func nestedSearchData(result search.SearchResult, key string) map[string]any { + root, ok := anyMap(result.Data) + if !ok { + return map[string]any{} + } + if nested, ok := anyMap(root[key]); ok { + return nested + } + if data, ok := anyMap(root["data"]); ok { + if nested, ok := anyMap(data[key]); ok { + return nested + } + return data + } + return root +} + +func anyMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case map[string]any: + return typed, true + case gin.H: + return map[string]any(typed), true + default: + return nil, false + } +} + +func firstMapValue(data map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := data[key]; ok { + return value + } + } + return nil +} + +func unixFromMapValue(value any) any { + switch typed := value.(type) { + case float64: + return int64(typed) + case int64: + return typed + case int: + return int64(typed) + case uint: + return int64(typed) + default: + return typed + } } diff --git a/internal/handler/api/v1/search_handler_test.go b/internal/handler/api/v1/search_handler_test.go index 2bc7a523..9cbd34d2 100644 --- a/internal/handler/api/v1/search_handler_test.go +++ b/internal/handler/api/v1/search_handler_test.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" @@ -20,9 +21,10 @@ type mockSearchRepo struct { convTotal int64 convErr error - messages []model.Message - msgTotal int64 - msgErr error + messages []model.Message + msgTotal int64 + msgErr error + msgFilter *search.SearchFilter contacts []model.Contact contactTotal int64 @@ -42,6 +44,7 @@ func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint } func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Message, int64, error) { + m.msgFilter = filter return m.messages, m.msgTotal, m.msgErr } @@ -117,7 +120,13 @@ func TestSearchHandler_GlobalSearch_Success(t *testing.T) { var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, true, body["success"]) + require.NotContains(t, body, "success") + payload, ok := body["payload"].(map[string]interface{}) + require.True(t, ok) + assert.Len(t, payload["conversations"], 1) + assert.Len(t, payload["contacts"], 1) + assert.Len(t, payload["messages"], 1) + assert.Len(t, payload["articles"], 1) } func TestSearchHandler_GlobalSearch_InvalidAccountID(t *testing.T) { @@ -156,11 +165,10 @@ func TestSearchHandler_GlobalSearch_WithFilterParams(t *testing.T) { var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, true, body["success"]) - - meta := body["meta"].(map[string]interface{}) - assert.Equal(t, float64(2), meta["page"]) - assert.Equal(t, float64(5), meta["per_page"]) + require.NotContains(t, body, "success") + payload, ok := body["payload"].(map[string]interface{}) + require.True(t, ok) + assert.Len(t, payload["conversations"], 1) } // ========== SearchConversations handler tests ========== @@ -182,11 +190,12 @@ func TestSearchHandler_SearchConversations_Success(t *testing.T) { var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, true, body["success"]) - - data := body["data"].(map[string]interface{}) - results := data["results"] - assert.NotNil(t, results) + require.NotContains(t, body, "success") + payload, ok := body["payload"].(map[string]interface{}) + require.True(t, ok) + results, ok := payload["conversations"].([]interface{}) + require.True(t, ok) + require.Len(t, results, 1) } func TestSearchHandler_SearchConversations_InvalidAccountID(t *testing.T) { @@ -227,18 +236,62 @@ func TestSearchHandler_SearchMessages_Success(t *testing.T) { router := setupSearchHandlerRouter(handler) w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello&page=1&per_page=10", nil) + req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello&page=1", nil) router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, true, body["success"]) + require.NotContains(t, body, "success") + payload, ok := body["payload"].(map[string]interface{}) + require.True(t, ok) + results, ok := payload["messages"].([]interface{}) + require.True(t, ok) + require.Len(t, results, 1) + require.NotNil(t, repo.msgFilter) + assert.Equal(t, 15, repo.msgFilter.PerPage) +} - data := body["data"].(map[string]interface{}) - results := data["results"] - assert.NotNil(t, results) +func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) { + svc := search.NewSearchServiceWithEngine(&stubSearchEngine{ + resp: &search.SearchResponse{ + Results: []search.SearchResult{{ + Type: search.ResultTypeMessage, + ID: 9, + AccountID: 1, + Data: map[string]any{ + "data": map[string]any{ + "message": map[string]any{ + "id": float64(9), + "content": "hello from meili", + "account_id": float64(1), + "conversation_id": float64(3), + "message_type": "incoming", + "created_at_ts": float64(1700000000), + }, + }, + }, + }}, + ByType: map[string]int64{"message": 1}, + }, + }, nil) + handler := NewSearchHandler(svc) + 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) + message := messages[0].(map[string]any) + assert.Equal(t, float64(0), message["message_type"]) + assert.Equal(t, float64(1700000000), message["created_at"]) } func TestSearchHandler_SearchMessages_InvalidAccountID(t *testing.T) { @@ -286,11 +339,14 @@ func TestSearchHandler_SearchContacts_Success(t *testing.T) { var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, true, body["success"]) - - data := body["data"].(map[string]interface{}) - results := data["results"] - assert.NotNil(t, results) + require.NotContains(t, body, "success") + payload, ok := body["payload"].(map[string]interface{}) + require.True(t, ok) + results, ok := payload["contacts"].([]interface{}) + require.True(t, ok) + require.Len(t, results, 1) + contact := results[0].(map[string]interface{}) + assert.Equal(t, "Alice", contact["name"]) } func TestSearchHandler_SearchContacts_InvalidAccountID(t *testing.T) { @@ -338,11 +394,12 @@ func TestSearchHandler_SearchArticles_Success(t *testing.T) { var body map[string]interface{} assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, true, body["success"]) - - data := body["data"].(map[string]interface{}) - results := data["results"] - assert.NotNil(t, results) + require.NotContains(t, body, "success") + payload, ok := body["payload"].(map[string]interface{}) + require.True(t, ok) + results, ok := payload["articles"].([]interface{}) + require.True(t, ok) + require.Len(t, results, 1) } func TestSearchHandler_SearchArticles_InvalidAccountID(t *testing.T) { @@ -370,3 +427,24 @@ func TestSearchHandler_SearchArticles_ServiceError(t *testing.T) { assert.Equal(t, http.StatusUnprocessableEntity, w.Code) } + +type stubSearchEngine struct { + resp *search.SearchResponse + err error +} + +func (s *stubSearchEngine) Search(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) (*search.SearchResponse, error) { + return s.resp, s.err +} + +func (s *stubSearchEngine) IndexDocument(ctx context.Context, doc search.SearchDocument) error { + return nil +} +func (s *stubSearchEngine) IndexBatch(ctx context.Context, docs []search.SearchDocument) error { + return nil +} +func (s *stubSearchEngine) DeleteDocument(ctx context.Context, docType search.SearchResultType, accountID uint, id uint) error { + return nil +} +func (s *stubSearchEngine) Bootstrap(ctx context.Context) error { return nil } +func (s *stubSearchEngine) Close() error { return nil } diff --git a/internal/repository/search_repo.go b/internal/repository/search_repo.go index 415faf75..9cfac603 100644 --- a/internal/repository/search_repo.go +++ b/internal/repository/search_repo.go @@ -26,6 +26,7 @@ type RepoSearchFilter struct { ContactSource string MessageType string SenderType string + SenderID *uint ContentType string Private *bool DateFrom *time.Time @@ -95,6 +96,7 @@ func searchFilterToRepo(f *search.SearchFilter) *RepoSearchFilter { ContactSource: f.ContactSource, MessageType: f.MessageType, SenderType: f.SenderType, + SenderID: f.SenderID, ContentType: f.ContentType, Private: f.Private, DateFrom: f.DateFrom, @@ -426,6 +428,9 @@ func applyMessageFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB { if filter.SenderType != "" { q = q.Where("sender_type = ?", filter.SenderType) } + if filter.SenderID != nil { + q = q.Where("sender_id = ?", *filter.SenderID) + } // Content type filter if filter.ContentType != "" { diff --git a/internal/search/engine.go b/internal/search/engine.go index e6c3568f..51ef97f0 100644 --- a/internal/search/engine.go +++ b/internal/search/engine.go @@ -49,6 +49,7 @@ type SearchDocument struct { Priority string `json:"priority,omitempty"` MessageType string `json:"message_type,omitempty"` SenderType string `json:"sender_type,omitempty"` + SenderID *uint `json:"sender_id,omitempty"` ContentType string `json:"content_type,omitempty"` Private bool `json:"private"` ContactSource string `json:"contact_source,omitempty"` @@ -166,6 +167,7 @@ func MessageDocument(msg model.Message) SearchDocument { Status: msg.Status, MessageType: msg.MessageType, SenderType: msg.SenderType, + SenderID: msg.SenderID, ContentType: msg.ContentType, Private: msg.Private, InboxID: &inboxID, diff --git a/internal/search/engine_meili.go b/internal/search/engine_meili.go index be8ee014..122e51a1 100644 --- a/internal/search/engine_meili.go +++ b/internal/search/engine_meili.go @@ -174,7 +174,7 @@ func (e *MeiliSearchEngine) ensureIndex(ctx context.Context, docType SearchResul func (e *MeiliSearchEngine) applySettings(ctx context.Context, docType SearchResultType) error { settings := map[string]interface{}{ "searchableAttributes": []string{"title", "content", "snippet", "status", "priority", "labels", "locale"}, - "filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "content_type", "private", "contact_source", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"}, + "filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"}, "sortableAttributes": []string{"created_at_ts", "updated_at_ts", "id"}, } resp, err := e.client.R(). @@ -210,6 +210,9 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul if filter.SenderType != "" { parts = append(parts, fmt.Sprintf("sender_type = %q", filter.SenderType)) } + if filter.SenderID != nil { + parts = append(parts, fmt.Sprintf("sender_id = %d", *filter.SenderID)) + } if filter.ContentType != "" { parts = append(parts, fmt.Sprintf("content_type = %q", filter.ContentType)) } diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go index b032f4c4..8cf2e95b 100644 --- a/internal/search/engine_test.go +++ b/internal/search/engine_test.go @@ -77,6 +77,24 @@ func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) { assert.Equal(t, "ada", requestBody["q"]) } +func TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter(t *testing.T) { + var requestBody map[string]interface{} + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) + return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil + }) + + engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) + engine.client.SetTransport(transport) + senderID := uint(77) + filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, SenderType: "contact", SenderID: &senderID} + _, err := engine.Search(context.Background(), 42, "hello", filter) + + require.NoError(t, err) + assert.Equal(t, "account_id = 42 AND sender_type = \"contact\" AND sender_id = 77", requestBody["filter"]) +} + func TestMeiliSearchEngine_IndexAndDeleteDocument(t *testing.T) { seen := []string{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { diff --git a/internal/search/search_filter.go b/internal/search/search_filter.go index a3bed4bb..09ed07f5 100644 --- a/internal/search/search_filter.go +++ b/internal/search/search_filter.go @@ -1,6 +1,7 @@ package search import ( + "strconv" "strings" "time" @@ -13,8 +14,8 @@ import ( type SearchMode string const ( - SearchModeILike SearchMode = "ilike" // ILIKE %query% — substring match (default) - SearchModeTrigram SearchMode = "trigram" // pg_trgm % operator — fuzzy similarity match + SearchModeILike SearchMode = "ilike" // ILIKE %query% — substring match (default) + SearchModeTrigram SearchMode = "trigram" // pg_trgm % operator — fuzzy similarity match ) // SearchFilter holds advanced filtering parameters for search queries. @@ -28,34 +29,35 @@ type SearchFilter struct { Types []SearchResultType `form:"types"` // Conversation-specific filters - Status []string `form:"status"` // open, resolved, pending, snoozed - Priority []string `form:"priority"` // none, low, medium, high, urgent - AssigneeID *uint `form:"assignee_id"` // agent assigned to conversation - TeamID *uint `form:"team_id"` // team assigned to conversation - InboxID *uint `form:"inbox_id"` // inbox filter - Labels []string `form:"labels"` // label filter (comma-separated) + Status []string `form:"status"` // open, resolved, pending, snoozed + Priority []string `form:"priority"` // none, low, medium, high, urgent + AssigneeID *uint `form:"assignee_id"` // agent assigned to conversation + TeamID *uint `form:"team_id"` // team assigned to conversation + InboxID *uint `form:"inbox_id"` // inbox filter + Labels []string `form:"labels"` // label filter (comma-separated) // Contact-specific filters ContactSource string `form:"contact_source"` // email, phone, website, api, etc. // Message-specific filters - MessageType string `form:"message_type"` // incoming, outgoing, activity, template - SenderType string `form:"sender_type"` // contact, agent, bot - ContentType string `form:"content_type"` // text, file, image, etc - Private *bool `form:"private"` // true = private notes only + MessageType string `form:"message_type"` // incoming, outgoing, activity, template + SenderType string `form:"sender_type"` // contact, agent, bot + SenderID *uint `form:"sender_id"` // parsed from Chatwoot from=contact:1/agent:1 + ContentType string `form:"content_type"` // text, file, image, etc + Private *bool `form:"private"` // true = private notes only // Article-specific filters (Knowledge Base) - PortalID *uint `form:"portal_id"` // portal to search within - ArticleStatus string `form:"article_status"` // draft, published, archived - ArticleLocale string `form:"locale"` // language locale filter (en, zh, etc.) + PortalID *uint `form:"portal_id"` // portal to search within + ArticleStatus string `form:"article_status"` // draft, published, archived + ArticleLocale string `form:"locale"` // language locale filter (en, zh, etc.) // Date range filters (apply to created_at) DateFrom *time.Time `form:"date_from"` DateTo *time.Time `form:"date_to"` // Sort parameters - SortBy string `form:"sort_by"` // created_at, updated_at, last_activity_at, priority, id - SortOrder string `form:"sort_order"` // asc, desc (default: desc) + SortBy string `form:"sort_by"` // created_at, updated_at, last_activity_at, priority, id + SortOrder string `form:"sort_order"` // asc, desc (default: desc) // Pagination Page int `form:"page"` @@ -141,6 +143,12 @@ func ParseSearchFilter(c *gin.Context) SearchFilter { f.ContactSource = c.Query("contact_source") f.MessageType = c.Query("message_type") f.SenderType = c.Query("sender_type") + if senderType, senderID := parseSearchFromParam(c.Query("from")); senderID != nil { + f.SenderType = senderType + f.SenderID = senderID + } else if v, err := parseUintQuery(c, "sender_id"); err == nil && v != 0 { + f.SenderID = &v + } f.ContentType = c.Query("content_type") // Parse article-specific filters @@ -156,15 +164,22 @@ func ParseSearchFilter(c *gin.Context) SearchFilter { f.Private = &b } - // Parse date range - if v := c.Query("date_from"); v != "" { + // Parse date range. Chatwoot's dashboard search client sends Unix seconds + // as since/until, while older local callers use RFC3339 date_from/date_to. + if v := firstNonEmptyQuery(c, "since", "date_from"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { f.DateFrom = &t + } else if unix, err := parseInt64(v); err == nil { + t := time.Unix(unix, 0) + f.DateFrom = &t } } - if v := c.Query("date_to"); v != "" { + if v := firstNonEmptyQuery(c, "until", "date_to"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { f.DateTo = &t + } else if unix, err := parseInt64(v); err == nil { + t := time.Unix(unix, 0) + f.DateTo = &t } } @@ -260,6 +275,40 @@ func parseUint(s string) (uint, error) { return uint(n), nil } +func parseInt64(s string) (int64, error) { + return strconv.ParseInt(s, 10, 64) +} + +func firstNonEmptyQuery(c *gin.Context, keys ...string) string { + for _, key := range keys { + if value := c.Query(key); value != "" { + return value + } + } + return "" +} + +func parseSearchFromParam(value string) (string, *uint) { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return "", nil + } + senderType := "" + switch strings.ToLower(parts[0]) { + case "contact": + senderType = "contact" + case "agent": + senderType = "agent" + default: + return "", nil + } + id, err := parseUint(parts[1]) + if err != nil || id == 0 { + return "", nil + } + return senderType, &id +} + func parseUint64(s string) (uint64, error) { var n uint64 for _, c := range s { @@ -269,4 +318,4 @@ func parseUint64(s string) (uint64, error) { n = n*10 + uint64(c-'0') } return n, nil -} \ No newline at end of file +} diff --git a/internal/search/search_filter_test.go b/internal/search/search_filter_test.go index 4adcd8bb..a6c091ea 100644 --- a/internal/search/search_filter_test.go +++ b/internal/search/search_filter_test.go @@ -164,4 +164,28 @@ func TestParseSearchFilter_SearchMode(t *testing.T) { sf := ParseSearchFilter(c) assert.Equal(t, SearchModeTrigram, sf.SearchMode) }) -} \ No newline at end of file +} + +func TestParseSearchFilter_ChatwootSearchParams(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{ + URL: &url.URL{Path: "/search", RawQuery: "since=1700000000&until=1700003600&from=contact:42&inbox_id=7"}, + } + + sf := ParseSearchFilter(c) + + if assert.NotNil(t, sf.DateFrom) { + assert.Equal(t, int64(1700000000), sf.DateFrom.Unix()) + } + if assert.NotNil(t, sf.DateTo) { + assert.Equal(t, int64(1700003600), sf.DateTo.Unix()) + } + assert.Equal(t, "contact", sf.SenderType) + if assert.NotNil(t, sf.SenderID) { + assert.Equal(t, uint(42), *sf.SenderID) + } + if assert.NotNil(t, sf.InboxID) { + assert.Equal(t, uint(7), *sf.InboxID) + } +} diff --git a/internal/search/search_service.go b/internal/search/search_service.go index 6a9e8d54..7c61d529 100644 --- a/internal/search/search_service.go +++ b/internal/search/search_service.go @@ -41,7 +41,7 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query if query == "" && len(filter.Status) == 0 && len(filter.Priority) == 0 && filter.AssigneeID == nil && filter.TeamID == nil && filter.InboxID == nil && len(filter.Labels) == 0 && filter.DateFrom == nil && filter.DateTo == nil && - filter.MessageType == "" && filter.SenderType == "" && filter.ContentType == "" && + filter.MessageType == "" && filter.SenderType == "" && filter.SenderID == nil && filter.ContentType == "" && filter.Private == nil && filter.ContactSource == "" && filter.PortalID == nil && filter.ArticleStatus == "" && filter.ArticleLocale == "" { // No query and no filters — return empty results