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
+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())
})
}
}