feat(search): align contact resolved scope

This commit is contained in:
2026-06-07 05:41:34 +08:00
parent 0baee8e47a
commit 7b232ce850
11 changed files with 294 additions and 97 deletions
+1 -1
View File
@@ -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
+60 -41
View File
@@ -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 != "" {
+39 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 {
+12 -3
View File
@@ -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 {
+52
View File
@@ -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) {
+3
View File
@@ -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
+33 -4
View File
@@ -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
+41 -3
View File
@@ -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")