feat(search): gate advanced filters
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")}
|
||||
|
||||
Reference in New Issue
Block a user