feat(search): align chatwoot search payloads
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,4 +164,28 @@ func TestParseSearchFilter_SearchMode(t *testing.T) {
|
||||
sf := ParseSearchFilter(c)
|
||||
assert.Equal(t, SearchModeTrigram, sf.SearchMode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user