feat(search): align contact resolved scope
This commit is contained in:
@@ -220,7 +220,7 @@ func (h *SearchHandler) SearchContacts(c *gin.Context) {
|
||||
// Force type to contacts only
|
||||
filter.Types = []search.SearchResultType{search.ResultTypeContact}
|
||||
|
||||
results, total, svcErr := h.svc.SearchContacts(c.Request.Context(), accountID, query, &filter)
|
||||
results, total, svcErr := h.svc.SearchResolvedContacts(c.Request.Context(), accountID, query, &filter)
|
||||
if svcErr != nil {
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "contact search failed")
|
||||
return
|
||||
|
||||
@@ -16,25 +16,27 @@ import (
|
||||
// This is a repository-level mirror of the search.SearchFilter struct to avoid
|
||||
// an import cycle between internal/search and internal/repository.
|
||||
type RepoSearchFilter struct {
|
||||
SearchMode search.SearchMode // ilike or trigram
|
||||
Status []string
|
||||
Priority []string
|
||||
AssigneeID *uint
|
||||
TeamID *uint
|
||||
InboxID *uint
|
||||
Labels []string
|
||||
ContactSource string
|
||||
MessageType string
|
||||
SenderType string
|
||||
SenderID *uint
|
||||
ContentType string
|
||||
Private *bool
|
||||
DateFrom *time.Time
|
||||
DateTo *time.Time
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Page int
|
||||
PerPage int
|
||||
SearchMode search.SearchMode // ilike or trigram
|
||||
Status []string
|
||||
Priority []string
|
||||
AssigneeID *uint
|
||||
TeamID *uint
|
||||
InboxID *uint
|
||||
Labels []string
|
||||
ContactSource string
|
||||
ContactResolvedScope bool
|
||||
ContactCRMV2 bool
|
||||
MessageType string
|
||||
SenderType string
|
||||
SenderID *uint
|
||||
ContentType string
|
||||
Private *bool
|
||||
DateFrom *time.Time
|
||||
DateTo *time.Time
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Page int
|
||||
PerPage int
|
||||
|
||||
// Article-specific filters (Knowledge Base)
|
||||
PortalID *uint
|
||||
@@ -86,28 +88,30 @@ func searchFilterToRepo(f *search.SearchFilter) *RepoSearchFilter {
|
||||
mode = search.SearchModeILike
|
||||
}
|
||||
return &RepoSearchFilter{
|
||||
SearchMode: mode,
|
||||
Status: f.Status,
|
||||
Priority: f.Priority,
|
||||
AssigneeID: f.AssigneeID,
|
||||
TeamID: f.TeamID,
|
||||
InboxID: f.InboxID,
|
||||
Labels: f.Labels,
|
||||
ContactSource: f.ContactSource,
|
||||
MessageType: f.MessageType,
|
||||
SenderType: f.SenderType,
|
||||
SenderID: f.SenderID,
|
||||
ContentType: f.ContentType,
|
||||
Private: f.Private,
|
||||
DateFrom: f.DateFrom,
|
||||
DateTo: f.DateTo,
|
||||
SortBy: f.SortBy,
|
||||
SortOrder: f.SortOrder,
|
||||
Page: f.Page,
|
||||
PerPage: f.PerPage,
|
||||
PortalID: f.PortalID,
|
||||
ArticleStatus: f.ArticleStatus,
|
||||
ArticleLocale: f.ArticleLocale,
|
||||
SearchMode: mode,
|
||||
Status: f.Status,
|
||||
Priority: f.Priority,
|
||||
AssigneeID: f.AssigneeID,
|
||||
TeamID: f.TeamID,
|
||||
InboxID: f.InboxID,
|
||||
Labels: f.Labels,
|
||||
ContactSource: f.ContactSource,
|
||||
ContactResolvedScope: f.ContactResolvedScope,
|
||||
ContactCRMV2: f.ContactCRMV2,
|
||||
MessageType: f.MessageType,
|
||||
SenderType: f.SenderType,
|
||||
SenderID: f.SenderID,
|
||||
ContentType: f.ContentType,
|
||||
Private: f.Private,
|
||||
DateFrom: f.DateFrom,
|
||||
DateTo: f.DateTo,
|
||||
SortBy: f.SortBy,
|
||||
SortOrder: f.SortOrder,
|
||||
Page: f.Page,
|
||||
PerPage: f.PerPage,
|
||||
PortalID: f.PortalID,
|
||||
ArticleStatus: f.ArticleStatus,
|
||||
ArticleLocale: f.ArticleLocale,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,12 +242,27 @@ func (r *SearchRepo) SearchContacts(ctx context.Context, accountID uint, query s
|
||||
return r.searchContactsInternal(ctx, accountID, query, repoFilter)
|
||||
}
|
||||
|
||||
func (r *SearchRepo) AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool {
|
||||
var account model.Account
|
||||
if err := r.db.WithContext(ctx).Select("feature_flags").Where("id = ?", accountID).First(&account).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return contactFeatureFlagEnabled(account.FeatureFlags, flag)
|
||||
}
|
||||
|
||||
// searchContactsInternal is the internal implementation using *RepoSearchFilter.
|
||||
func (r *SearchRepo) searchContactsInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Contact, int64, error) {
|
||||
var contacts []model.Contact
|
||||
var total int64
|
||||
|
||||
q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID)
|
||||
if filter.ContactResolvedScope {
|
||||
if filter.ContactCRMV2 {
|
||||
q = q.Where("contacts.contact_type = ?", "lead")
|
||||
} else {
|
||||
q = q.Where("(contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '')")
|
||||
}
|
||||
}
|
||||
|
||||
// Text search on contact fields — ILIKE or pg_trgm fuzzy match
|
||||
if query != "" {
|
||||
|
||||
@@ -271,6 +271,44 @@ func TestSearchRepo_SearchContacts_EmailMatch(t *testing.T) {
|
||||
assert.NotEmpty(t, results)
|
||||
}
|
||||
|
||||
func TestSearchRepo_SearchContacts_ResolvedScope(t *testing.T) {
|
||||
skipIfSQLite(t)
|
||||
db := setupTestDB(t)
|
||||
repo := NewSearchRepo(db)
|
||||
|
||||
account := createTestAccountForSearch(t, db)
|
||||
resolved := createTestContactForSearch(t, db, account.ID, "Scope Resolved", "scope@example.com", "")
|
||||
unresolved := createTestContactForSearch(t, db, account.ID, "Scope Anonymous", "", "")
|
||||
|
||||
filter := &search.SearchFilter{Page: 1, PerPage: 25, ContactResolvedScope: true}
|
||||
results, total, err := repo.SearchContacts(context.Background(), account.ID, "Scope", filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, resolved.ID, results[0].ID)
|
||||
assert.NotEqual(t, unresolved.ID, results[0].ID)
|
||||
}
|
||||
|
||||
func TestSearchRepo_SearchContacts_ResolvedScopeCRMV2(t *testing.T) {
|
||||
skipIfSQLite(t)
|
||||
db := setupTestDB(t)
|
||||
repo := NewSearchRepo(db)
|
||||
|
||||
account := createTestAccountForSearch(t, db)
|
||||
require.NoError(t, db.Model(account).Update("feature_flags", `{"crm_v2":true}`).Error)
|
||||
lead := createTestContactForSearch(t, db, account.ID, "Scope Lead", "", "")
|
||||
require.NoError(t, db.Model(lead).Update("contact_type", "lead").Error)
|
||||
customer := createTestContactForSearch(t, db, account.ID, "Scope Customer", "customer@example.com", "")
|
||||
require.NoError(t, db.Model(customer).Update("contact_type", "customer").Error)
|
||||
|
||||
filter := &search.SearchFilter{Page: 1, PerPage: 25, ContactResolvedScope: true, ContactCRMV2: repo.AccountFeatureEnabled(context.Background(), account.ID, "crm_v2")}
|
||||
results, total, err := repo.SearchContacts(context.Background(), account.ID, "Scope", filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, lead.ID, results[0].ID)
|
||||
}
|
||||
|
||||
// ========== SearchArticles DB tests (PG only for ILIKE) ==========
|
||||
|
||||
func TestSearchRepo_SearchArticles_TitleMatch(t *testing.T) {
|
||||
@@ -377,4 +415,4 @@ func TestSearchFilter_IsTrigram_Unit(t *testing.T) {
|
||||
sf := &search.SearchFilter{SearchMode: search.SearchModeTrigram}
|
||||
assert.True(t, sf.IsTrigram())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+41
-36
@@ -38,32 +38,34 @@ type SearchEngine interface {
|
||||
// SearchDocument is the normalized payload stored in Meilisearch. Data keeps the
|
||||
// entity payload available for Chatwoot-compatible frontend responses.
|
||||
type SearchDocument struct {
|
||||
UID string `json:"uid"`
|
||||
ID uint `json:"id"`
|
||||
Type SearchResultType `json:"type"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
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"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
AssigneeID *uint `json:"assignee_id,omitempty"`
|
||||
TeamID *uint `json:"team_id,omitempty"`
|
||||
InboxID *uint `json:"inbox_id,omitempty"`
|
||||
ContactID *uint `json:"contact_id,omitempty"`
|
||||
ConversationID *uint `json:"conversation_id,omitempty"`
|
||||
PortalID *uint `json:"portal_id,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
CreatedAtTS int64 `json:"created_at_ts"`
|
||||
UpdatedAtTS int64 `json:"updated_at_ts"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
UID string `json:"uid"`
|
||||
ID uint `json:"id"`
|
||||
Type SearchResultType `json:"type"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
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"`
|
||||
ContactType string `json:"contact_type,omitempty"`
|
||||
ContactHasDetails bool `json:"contact_has_details"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
AssigneeID *uint `json:"assignee_id,omitempty"`
|
||||
TeamID *uint `json:"team_id,omitempty"`
|
||||
InboxID *uint `json:"inbox_id,omitempty"`
|
||||
ContactID *uint `json:"contact_id,omitempty"`
|
||||
ConversationID *uint `json:"conversation_id,omitempty"`
|
||||
PortalID *uint `json:"portal_id,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
CreatedAtTS int64 `json:"created_at_ts"`
|
||||
UpdatedAtTS int64 `json:"updated_at_ts"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (d *SearchDocument) ensureUID() {
|
||||
@@ -181,17 +183,20 @@ func MessageDocument(msg model.Message) SearchDocument {
|
||||
}
|
||||
|
||||
func ContactDocument(contact model.Contact) SearchDocument {
|
||||
content := strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " "))
|
||||
doc := SearchDocument{
|
||||
ID: contact.ID,
|
||||
Type: ResultTypeContact,
|
||||
AccountID: contact.AccountID,
|
||||
Title: contact.Name,
|
||||
Content: strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " ")),
|
||||
Snippet: contactSnippet(&contact, ""),
|
||||
ContactSource: contact.ContactType,
|
||||
CreatedAtTS: timestamp(contact.CreatedAt),
|
||||
UpdatedAtTS: timestamp(contact.UpdatedAt),
|
||||
Data: map[string]interface{}{"contact": contact},
|
||||
ID: contact.ID,
|
||||
Type: ResultTypeContact,
|
||||
AccountID: contact.AccountID,
|
||||
Title: contact.Name,
|
||||
Content: content,
|
||||
Snippet: contactSnippet(&contact, ""),
|
||||
ContactSource: contact.ContactType,
|
||||
ContactType: contact.ContactType,
|
||||
ContactHasDetails: content != "",
|
||||
CreatedAtTS: timestamp(contact.CreatedAt),
|
||||
UpdatedAtTS: timestamp(contact.UpdatedAt),
|
||||
Data: map[string]interface{}{"contact": contact},
|
||||
}
|
||||
doc.ensureUID()
|
||||
return doc
|
||||
|
||||
@@ -19,7 +19,7 @@ func NewSearchEngineDB(repo SearchRepoInterface) *SearchEngineDB {
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) {
|
||||
return (&SearchService{searchRepo: e.repo}).GlobalSearch(ctx, accountID, query, filter)
|
||||
return NewSearchService(e.repo).GlobalSearch(ctx, accountID, query, filter)
|
||||
}
|
||||
|
||||
func (e *SearchEngineDB) IndexDocument(ctx context.Context, doc SearchDocument) error {
|
||||
|
||||
@@ -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", "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"},
|
||||
"filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "contact_type", "contact_has_details", "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().
|
||||
@@ -220,8 +220,17 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
|
||||
parts = append(parts, fmt.Sprintf("private = %t", *filter.Private))
|
||||
}
|
||||
}
|
||||
if docType == ResultTypeContact && filter.ContactSource != "" {
|
||||
parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource))
|
||||
if docType == ResultTypeContact {
|
||||
if filter.ContactResolvedScope {
|
||||
if filter.ContactCRMV2 {
|
||||
parts = append(parts, `contact_type = "lead"`)
|
||||
} else {
|
||||
parts = append(parts, "contact_has_details = true")
|
||||
}
|
||||
}
|
||||
if filter.ContactSource != "" {
|
||||
parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource))
|
||||
}
|
||||
}
|
||||
if docType == ResultTypeArticle || docType == ResultTypeHelpCenter {
|
||||
if filter.PortalID != nil {
|
||||
|
||||
@@ -53,6 +53,24 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) {
|
||||
assert.Equal(t, []string{"billing", "urgent"}, doc.Labels)
|
||||
}
|
||||
|
||||
func TestContactDocumentSetsResolvedScopeFields(t *testing.T) {
|
||||
doc := ContactDocument(model.Contact{
|
||||
Base: model.Base{ID: 5},
|
||||
AccountID: 2,
|
||||
Name: "Grace Hopper",
|
||||
Email: "grace@example.com",
|
||||
ContactType: "lead",
|
||||
})
|
||||
|
||||
assert.Equal(t, ResultTypeContact, doc.Type)
|
||||
assert.Equal(t, "lead", doc.ContactType)
|
||||
assert.Equal(t, "lead", doc.ContactSource)
|
||||
assert.True(t, doc.ContactHasDetails)
|
||||
|
||||
anonymous := ContactDocument(model.Contact{Base: model.Base{ID: 6}, AccountID: 2, Name: "Anonymous"})
|
||||
assert.False(t, anonymous.ContactHasDetails)
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) {
|
||||
var requestBody map[string]interface{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
@@ -77,6 +95,40 @@ func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) {
|
||||
assert.Equal(t, "ada", requestBody["q"])
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_SearchSendsResolvedContactFilter(t *testing.T) {
|
||||
var requestBody map[string]interface{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
require.Equal(t, "/indexes/gochat_contacts/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)
|
||||
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}, ContactResolvedScope: true}
|
||||
_, err := engine.Search(context.Background(), 42, "ada", filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "account_id = 42 AND contact_has_details = true", requestBody["filter"])
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_SearchSendsCRMV2ResolvedContactFilter(t *testing.T) {
|
||||
var requestBody map[string]interface{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
require.Equal(t, "/indexes/gochat_contacts/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)
|
||||
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}, ContactResolvedScope: true, ContactCRMV2: true}
|
||||
_, err := engine.Search(context.Background(), 42, "ada", filter)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `account_id = 42 AND contact_type = "lead"`, requestBody["filter"])
|
||||
}
|
||||
|
||||
func TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter(t *testing.T) {
|
||||
var requestBody map[string]interface{}
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -38,6 +38,9 @@ type SearchFilter struct {
|
||||
|
||||
// Contact-specific filters
|
||||
ContactSource string `form:"contact_source"` // email, phone, website, api, etc.
|
||||
// Chatwoot global search applies Contact.resolved_contacts to contact results.
|
||||
ContactResolvedScope bool `form:"-"`
|
||||
ContactCRMV2 bool `form:"-"`
|
||||
|
||||
// Message-specific filters
|
||||
MessageType string `form:"message_type"` // incoming, outgoing, activity, template
|
||||
|
||||
@@ -13,19 +13,31 @@ import (
|
||||
// and contacts with advanced filtering and sorting.
|
||||
// Reference: Chatwoot GlobalSearchService — cross-entity search with filter params.
|
||||
type SearchService struct {
|
||||
searchRepo SearchRepoInterface
|
||||
engine SearchEngine
|
||||
searchRepo SearchRepoInterface
|
||||
engine SearchEngine
|
||||
featureResolver accountFeatureResolver
|
||||
}
|
||||
|
||||
type accountFeatureResolver interface {
|
||||
AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool
|
||||
}
|
||||
|
||||
// NewSearchService creates a new SearchService.
|
||||
func NewSearchService(searchRepo SearchRepoInterface) *SearchService {
|
||||
return &SearchService{searchRepo: searchRepo}
|
||||
return &SearchService{searchRepo: searchRepo, featureResolver: searchFeatureResolver(searchRepo)}
|
||||
}
|
||||
|
||||
// NewSearchServiceWithEngine creates a SearchService backed by an explicit
|
||||
// SearchEngine. The repository remains available for db fallback and legacy tests.
|
||||
func NewSearchServiceWithEngine(engine SearchEngine, fallbackRepo SearchRepoInterface) *SearchService {
|
||||
return &SearchService{searchRepo: fallbackRepo, engine: engine}
|
||||
return &SearchService{searchRepo: fallbackRepo, engine: engine, featureResolver: searchFeatureResolver(fallbackRepo)}
|
||||
}
|
||||
|
||||
func searchFeatureResolver(repo SearchRepoInterface) accountFeatureResolver {
|
||||
if resolver, ok := repo.(accountFeatureResolver); ok {
|
||||
return resolver
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GlobalSearch performs a unified search across all searchable entity types
|
||||
@@ -56,8 +68,10 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
|
||||
}
|
||||
|
||||
if s.engine != nil {
|
||||
filter = s.withContactResolvedScope(ctx, accountID, filter)
|
||||
return s.engine.Search(ctx, accountID, query, filter)
|
||||
}
|
||||
filter = s.withContactResolvedScope(ctx, accountID, filter)
|
||||
|
||||
var allResults []SearchResult
|
||||
byType := map[string]int64{}
|
||||
@@ -256,6 +270,12 @@ func (s *SearchService) SearchContacts(ctx context.Context, accountID uint, quer
|
||||
return results, total, nil
|
||||
}
|
||||
|
||||
// SearchResolvedContacts matches Chatwoot SearchService#filter_contacts, which
|
||||
// applies Contact.resolved_contacts to global/entity search contact results.
|
||||
func (s *SearchService) SearchResolvedContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
|
||||
return s.SearchContacts(ctx, accountID, query, s.withContactResolvedScope(ctx, accountID, filter))
|
||||
}
|
||||
|
||||
// SearchCompanies performs a filtered company search.
|
||||
func (s *SearchService) SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
|
||||
if s.engine != nil {
|
||||
@@ -327,6 +347,15 @@ func cloneSearchFilter(filter *SearchFilter) *SearchFilter {
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (s *SearchService) withContactResolvedScope(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter {
|
||||
clone := cloneSearchFilter(filter)
|
||||
clone.ContactResolvedScope = true
|
||||
if s != nil && s.featureResolver != nil {
|
||||
clone.ContactCRMV2 = s.featureResolver.AccountFeatureEnabled(ctx, accountID, "crm_v2")
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func (s *SearchService) IndexDocument(ctx context.Context, doc SearchDocument) error {
|
||||
if s.engine == nil {
|
||||
return nil
|
||||
|
||||
@@ -22,9 +22,10 @@ type mockSearchRepo struct {
|
||||
msgCount int64
|
||||
msgErr error
|
||||
|
||||
contacts []model.Contact
|
||||
contactCount int64
|
||||
contactErr error
|
||||
contacts []model.Contact
|
||||
contactCount int64
|
||||
contactErr error
|
||||
contactFilter *SearchFilter
|
||||
|
||||
companies []model.Company
|
||||
companyCount int64
|
||||
@@ -33,6 +34,8 @@ type mockSearchRepo struct {
|
||||
articles []model.Article
|
||||
articleCount int64
|
||||
articleErr error
|
||||
|
||||
features map[string]bool
|
||||
}
|
||||
|
||||
func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) {
|
||||
@@ -44,9 +47,14 @@ func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, que
|
||||
}
|
||||
|
||||
func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Contact, int64, error) {
|
||||
m.contactFilter = filter
|
||||
return m.contacts, m.contactCount, m.contactErr
|
||||
}
|
||||
|
||||
func (m *mockSearchRepo) AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool {
|
||||
return m.features[flag]
|
||||
}
|
||||
|
||||
func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Company, int64, error) {
|
||||
return m.companies, m.companyCount, m.companyErr
|
||||
}
|
||||
@@ -201,6 +209,36 @@ func TestGlobalSearch_AllTypes(t *testing.T) {
|
||||
assert.Equal(t, int64(1), resp.ByType["article"])
|
||||
}
|
||||
|
||||
func TestGlobalSearch_AppliesResolvedContactScope(t *testing.T) {
|
||||
repo := &mockSearchRepo{}
|
||||
repo.contacts = []model.Contact{makeContact(20, 1, "John Doe", "john@example.com", "")}
|
||||
repo.contactCount = 1
|
||||
|
||||
svc := NewSearchService(repo)
|
||||
filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeContact}}
|
||||
|
||||
_, err := svc.GlobalSearch(context.Background(), 1, "john", filter)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, repo.contactFilter)
|
||||
assert.True(t, repo.contactFilter.ContactResolvedScope)
|
||||
assert.False(t, repo.contactFilter.ContactCRMV2)
|
||||
}
|
||||
|
||||
func TestGlobalSearch_AppliesCRMV2ResolvedContactScope(t *testing.T) {
|
||||
repo := &mockSearchRepo{features: map[string]bool{"crm_v2": true}}
|
||||
repo.contacts = []model.Contact{makeContact(20, 1, "Lead", "", "")}
|
||||
repo.contactCount = 1
|
||||
|
||||
svc := NewSearchService(repo)
|
||||
filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeContact}}
|
||||
|
||||
_, err := svc.GlobalSearch(context.Background(), 1, "lead", filter)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, repo.contactFilter)
|
||||
assert.True(t, repo.contactFilter.ContactResolvedScope)
|
||||
assert.True(t, repo.contactFilter.ContactCRMV2)
|
||||
}
|
||||
|
||||
func TestGlobalSearch_RepoErrorGraceful(t *testing.T) {
|
||||
repo := &mockSearchRepo{}
|
||||
repo.convErr = fmt.Errorf("db error")
|
||||
|
||||
Reference in New Issue
Block a user