feat(search): gate advanced filters

This commit is contained in:
2026-06-07 07:34:20 +08:00
parent 112ae43955
commit 7fb1cdab22
8 changed files with 158 additions and 79 deletions
+66 -63
View File
@@ -16,30 +16,31 @@ 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
ContactResolvedScope bool
ContactCRMV2 bool
MessageType string
SenderType string
SenderID *uint
ContentType string
Private *bool
EnforceInboxAccess bool
AccessibleInboxIDs []uint
MessageCreatedAfter *time.Time
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
AdvancedSearchEnabled bool
MessageType string
SenderType string
SenderID *uint
ContentType string
Private *bool
EnforceInboxAccess bool
AccessibleInboxIDs []uint
MessageCreatedAfter *time.Time
DateFrom *time.Time
DateTo *time.Time
SortBy string
SortOrder string
Page int
PerPage int
// Article-specific filters (Knowledge Base)
PortalID *uint
@@ -91,33 +92,34 @@ 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,
ContactResolvedScope: f.ContactResolvedScope,
ContactCRMV2: f.ContactCRMV2,
MessageType: f.MessageType,
SenderType: f.SenderType,
SenderID: f.SenderID,
ContentType: f.ContentType,
Private: f.Private,
EnforceInboxAccess: f.EnforceInboxAccess,
AccessibleInboxIDs: append([]uint(nil), f.AccessibleInboxIDs...),
MessageCreatedAfter: f.MessageCreatedAfter,
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,
AdvancedSearchEnabled: f.AdvancedSearchEnabled,
MessageType: f.MessageType,
SenderType: f.SenderType,
SenderID: f.SenderID,
ContentType: f.ContentType,
Private: f.Private,
EnforceInboxAccess: f.EnforceInboxAccess,
AccessibleInboxIDs: append([]uint(nil), f.AccessibleInboxIDs...),
MessageCreatedAfter: f.MessageCreatedAfter,
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,
}
}
@@ -482,12 +484,18 @@ func applyMessageFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
q = q.Where("message_type = ?", filter.MessageType)
}
// Sender type filter
if filter.SenderType != "" {
q = q.Where("sender_type = ?", filter.SenderType)
}
if filter.SenderID != nil {
q = q.Where("sender_id = ?", *filter.SenderID)
if filter.AdvancedSearchEnabled {
// Sender and explicit inbox filters are Chatwoot advanced-search filters.
if filter.SenderType != "" {
q = q.Where("sender_type = ?", filter.SenderType)
}
if filter.SenderID != nil {
q = q.Where("sender_id = ?", *filter.SenderID)
}
if filter.InboxID != nil && searchInboxIDAllowed(filter, *filter.InboxID) {
q = q.Where("inbox_id = ?", *filter.InboxID)
}
}
// Content type filter
@@ -500,11 +508,6 @@ func applyMessageFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
q = q.Where("private = ?", *filter.Private)
}
// Inbox ID filter (messages also belong to an inbox)
if filter.InboxID != nil && searchInboxIDAllowed(filter, *filter.InboxID) {
q = q.Where("inbox_id = ?", *filter.InboxID)
}
return q
}
@@ -582,7 +585,7 @@ func applyArticleFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
// applyDateRangeFilter applies a date range filter on created_at.
func applyDateRangeFilter(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil {
if filter == nil || !filter.AdvancedSearchEnabled {
return q
}
+27
View File
@@ -267,6 +267,33 @@ func TestSearchRepo_SearchMessages_MessageBaseExcludesOldMessages(t *testing.T)
assert.Equal(t, recentMsg.ID, results[0].ID)
}
func TestSearchRepo_SearchMessages_SenderFilterRequiresAdvancedSearch(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "")
contactID := uint(42)
userID := uint(77)
contactMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "contact message", "incoming")
require.NoError(t, db.Model(contactMsg).Updates(map[string]any{"sender_type": "contact", "sender_id": contactID}).Error)
userMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "user message", "outgoing")
require.NoError(t, db.Model(userMsg).Updates(map[string]any{"sender_type": "User", "sender_id": userID}).Error)
baseFilter := &search.SearchFilter{Page: 1, PerPage: 25, SenderType: "contact", SenderID: &contactID}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", baseFilter)
require.NoError(t, err)
assert.Equal(t, int64(2), total)
assert.Len(t, results, 2)
advancedFilter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, SenderType: "contact", SenderID: &contactID}
results, total, err = repo.SearchMessages(context.Background(), account.ID, "", advancedFilter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, contactMsg.ID, results[0].ID)
}
// ========== SearchConversations DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) {
+5 -5
View File
@@ -203,7 +203,7 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
if docType == ResultTypeConversation || docType == ResultTypeMessage {
parts = appendInboxAccessFilter(parts, filter)
}
if filter.InboxID != nil && searchFilterInboxAllowed(filter, *filter.InboxID) {
if filter.AdvancedSearchEnabled && filter.InboxID != nil && searchFilterInboxAllowed(filter, *filter.InboxID) {
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
}
if docType == ResultTypeMessage {
@@ -213,10 +213,10 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
if filter.MessageType != "" {
parts = append(parts, fmt.Sprintf("message_type = %q", filter.MessageType))
}
if filter.SenderType != "" {
if filter.AdvancedSearchEnabled && filter.SenderType != "" {
parts = append(parts, fmt.Sprintf("sender_type = %q", filter.SenderType))
}
if filter.SenderID != nil {
if filter.AdvancedSearchEnabled && filter.SenderID != nil {
parts = append(parts, fmt.Sprintf("sender_id = %d", *filter.SenderID))
}
if filter.ContentType != "" {
@@ -249,10 +249,10 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
parts = append(parts, fmt.Sprintf("locale = %q", filter.ArticleLocale))
}
}
if filter.DateFrom != nil {
if filter.AdvancedSearchEnabled && filter.DateFrom != nil {
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.DateFrom.Unix()))
}
if filter.DateTo != nil {
if filter.AdvancedSearchEnabled && filter.DateTo != nil {
parts = append(parts, fmt.Sprintf("created_at_ts <= %d", filter.DateTo.Unix()))
}
return strings.Join(parts, " AND ")
+22 -2
View File
@@ -141,13 +141,33 @@ func TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter(t *testing.T) {
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}
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, AdvancedSearchEnabled: true, 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_SearchSkipsAdvancedFiltersWhenFeatureDisabled(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)
inboxID := uint(9)
dateFrom := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, SenderType: "contact", SenderID: &senderID, InboxID: &inboxID, DateFrom: &dateFrom}
_, err := engine.Search(context.Background(), 42, "hello", filter)
require.NoError(t, err)
assert.Equal(t, "account_id = 42", requestBody["filter"])
}
func TestMeiliSearchEngine_SearchSendsInboxAccessFilter(t *testing.T) {
var requestBody map[string]interface{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -194,7 +214,7 @@ func TestMeiliSearchEngine_SearchIgnoresInaccessibleInboxParam(t *testing.T) {
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
engine.client.SetTransport(transport)
inboxID := uint(9)
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, InboxID: &inboxID, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{3, 5}}
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, AdvancedSearchEnabled: true, InboxID: &inboxID, EnforceInboxAccess: true, AccessibleInboxIDs: []uint{3, 5}}
_, err := engine.Search(context.Background(), 42, "hello", filter)
require.NoError(t, err)
+3 -2
View File
@@ -39,8 +39,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:"-"`
ContactResolvedScope bool `form:"-"`
ContactCRMV2 bool `form:"-"`
AdvancedSearchEnabled bool `form:"-"`
// Message-specific filters
MessageType string `form:"message_type"` // incoming, outgoing, activity, template
+14 -3
View File
@@ -88,10 +88,10 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
}
if s.engine != nil {
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter)))
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, s.withAdvancedSearchFeature(ctx, accountID, filter))))
return s.engine.Search(ctx, accountID, query, filter)
}
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter)))
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, s.withAdvancedSearchFeature(ctx, accountID, filter))))
var allResults []SearchResult
byType := map[string]int64{}
@@ -218,6 +218,7 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
// SearchConversations performs a filtered conversation search.
// Convenience method for conversation-only search with full filter support.
func (s *SearchService) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
filter = s.withAdvancedSearchFeature(ctx, accountID, filter)
filter = s.withSearchAccess(ctx, accountID, filter)
if s.engine != nil {
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeConversation)
@@ -244,7 +245,7 @@ func (s *SearchService) SearchConversations(ctx context.Context, accountID uint,
// SearchMessages performs a filtered message search.
// Convenience method for message-only search with full filter support.
func (s *SearchService) SearchMessages(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, filter))
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withAdvancedSearchFeature(ctx, accountID, filter)))
if s.engine != nil {
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeMessage)
}
@@ -270,6 +271,7 @@ func (s *SearchService) SearchMessages(ctx context.Context, accountID uint, quer
// SearchContacts performs a filtered contact search.
// Convenience method for contact-only search with full filter support.
func (s *SearchService) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
filter = s.withAdvancedSearchFeature(ctx, accountID, filter)
if s.engine != nil {
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeContact)
}
@@ -325,6 +327,7 @@ func (s *SearchService) SearchCompanies(ctx context.Context, accountID uint, que
// SearchArticles performs a filtered knowledge base article search.
// Convenience method for article-only search with full filter support.
func (s *SearchService) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) {
filter = s.withAdvancedSearchFeature(ctx, accountID, filter)
if s.engine != nil {
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeArticle)
}
@@ -379,6 +382,14 @@ func (s *SearchService) withMessageRecentBase(filter *SearchFilter) *SearchFilte
return clone
}
func (s *SearchService) withAdvancedSearchFeature(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter {
clone := cloneSearchFilter(filter)
if s != nil && s.featureResolver != nil {
clone.AdvancedSearchEnabled = s.featureResolver.AccountFeatureEnabled(ctx, accountID, "advanced_search")
}
return clone
}
func (s *SearchService) withContactResolvedScope(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter {
clone := cloneSearchFilter(filter)
clone.ContactResolvedScope = true
+14
View File
@@ -364,6 +364,20 @@ func TestSearchMessages_AppliesThreeMonthMessageBase(t *testing.T) {
}
}
func TestSearchMessages_AppliesAdvancedSearchFeatureFlag(t *testing.T) {
repo := &mockSearchRepo{
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
msgCount: 1,
features: map[string]bool{"advanced_search": true},
}
svc := NewSearchService(repo)
_, _, err := svc.SearchMessages(context.Background(), 1, "test", &SearchFilter{Page: 1, PerPage: 25})
require.NoError(t, err)
require.NotNil(t, repo.msgFilter)
assert.True(t, repo.msgFilter.AdvancedSearchEnabled)
}
func TestSearchContacts_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.contacts = []model.Contact{makeContact(5, 1, "Alice", "alice@test.com", "")}