feat(search): align time filters

This commit is contained in:
2026-06-07 07:49:07 +08:00
parent 7fb1cdab22
commit 9a7e455e1c
8 changed files with 213 additions and 37 deletions
+23 -9
View File
@@ -176,7 +176,7 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID
q = applyConversationFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
q = applyDateRangeFilter(q, filter, "last_activity_at", true)
// Count
if err := q.Count(&total).Error; err != nil {
@@ -230,7 +230,7 @@ func (r *SearchRepo) searchMessagesInternal(ctx context.Context, accountID uint,
q = applyMessageFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
q = applyDateRangeFilter(q, filter, "created_at", false)
// Count
if err := q.Count(&total).Error; err != nil {
@@ -323,7 +323,7 @@ func (r *SearchRepo) searchContactsInternal(ctx context.Context, accountID uint,
q = applyContactFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
q = applyDateRangeFilter(q, filter, "last_activity_at", true)
// Count
if err := q.Count(&total).Error; err != nil {
@@ -363,7 +363,7 @@ func (r *SearchRepo) searchCompaniesInternal(ctx context.Context, accountID uint
}
}
q = applyDateRangeFilter(q, filter)
q = applyDateRangeFilter(q, filter, "created_at", false)
if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("search companies count: %w", err)
}
@@ -408,7 +408,7 @@ func (r *SearchRepo) searchArticlesInternal(ctx context.Context, accountID uint,
q = applyArticleFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter)
q = applyDateRangeFilter(q, filter, "updated_at", false)
// Count
if err := q.Count(&total).Error; err != nil {
@@ -583,18 +583,32 @@ func applyArticleFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
return q
}
// applyDateRangeFilter applies a date range filter on created_at.
func applyDateRangeFilter(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
// applyDateRangeFilter applies Chatwoot SearchService time filters using the
// entity-specific column selected by the caller.
func applyDateRangeFilter(q *gorm.DB, filter *RepoSearchFilter, columnName string, unixSeconds bool) *gorm.DB {
if filter == nil || !filter.AdvancedSearchEnabled {
return q
}
if columnName == "" {
columnName = "created_at"
}
if filter.DateFrom != nil {
q = q.Where("created_at >= ?", filter.DateFrom)
q = q.Where(columnName+" >= ?", dateRangeValue(filter.DateFrom, unixSeconds))
}
if filter.DateTo != nil {
q = q.Where("created_at <= ?", filter.DateTo)
q = q.Where(columnName+" <= ?", dateRangeValue(filter.DateTo, unixSeconds))
}
return q
}
func dateRangeValue(value *time.Time, unixSeconds bool) any {
if value == nil {
return nil
}
if unixSeconds {
return value.Unix()
}
return value
}
+66 -2
View File
@@ -350,6 +350,28 @@ func TestSearchRepo_SearchConversations_WithStatusFilter(t *testing.T) {
assert.Equal(t, "open", results[0].Status)
}
func TestSearchRepo_SearchConversations_DateFilterUsesLastActivityAt(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
oldActivity := cutoff.Add(-time.Hour).Unix()
recentActivity := cutoff.Add(time.Hour).Unix()
oldConv := createTestConversationForSearch(t, db, account.ID, "open", "date-filter")
recentConv := createTestConversationForSearch(t, db, account.ID, "open", "date-filter")
require.NoError(t, db.Model(oldConv).Update("last_activity_at", oldActivity).Error)
require.NoError(t, db.Model(recentConv).Update("last_activity_at", recentActivity).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentConv.ID, results[0].ID)
}
// ========== SearchMessages DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchMessages_ContentMatch(t *testing.T) {
@@ -463,11 +485,33 @@ func TestSearchRepo_SearchContacts_ResolvedScopeCRMV2(t *testing.T) {
assert.Equal(t, lead.ID, results[0].ID)
}
func TestSearchRepo_SearchContacts_DateFilterUsesLastActivityAt(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
oldActivity := cutoff.Add(-time.Hour).Unix()
recentActivity := cutoff.Add(time.Hour).Unix()
oldContact := createTestContactForSearch(t, db, account.ID, "Old Activity", "old@example.com", "")
recentContact := createTestContactForSearch(t, db, account.ID, "Recent Activity", "recent@example.com", "")
require.NoError(t, db.Model(oldContact).Update("last_activity_at", oldActivity).Error)
require.NoError(t, db.Model(recentContact).Update("last_activity_at", recentActivity).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
results, total, err := repo.SearchContacts(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentContact.ID, results[0].ID)
}
// ========== SearchArticles DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchArticles_TitleMatch(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
db := setupTestDB(t, &model.Article{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
@@ -483,7 +527,7 @@ func TestSearchRepo_SearchArticles_TitleMatch(t *testing.T) {
func TestSearchRepo_SearchArticles_WithStatusFilter(t *testing.T) {
skipIfSQLite(t)
db := setupTestDB(t)
db := setupTestDB(t, &model.Article{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
@@ -499,6 +543,26 @@ func TestSearchRepo_SearchArticles_WithStatusFilter(t *testing.T) {
}
}
func TestSearchRepo_SearchArticles_DateFilterUsesUpdatedAt(t *testing.T) {
db := setupTestDB(t, &model.Article{})
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
cutoff := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
oldArticle := createTestArticleForSearch(t, db, account.ID, 1, "Old Updated Article", "desc", "content", "published")
recentArticle := createTestArticleForSearch(t, db, account.ID, 1, "Recent Updated Article", "desc", "content", "published")
require.NoError(t, db.Model(oldArticle).UpdateColumn("updated_at", cutoff.Add(-time.Hour)).Error)
require.NoError(t, db.Model(recentArticle).UpdateColumn("updated_at", cutoff.Add(time.Hour)).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, DateFrom: &cutoff}
results, total, err := repo.SearchArticles(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentArticle.ID, results[0].ID)
}
// ========== Interface satisfaction test ==========
func TestSearchRepo_SatisfiesSearchRepoInterface(t *testing.T) {
+25 -15
View File
@@ -65,6 +65,7 @@ type SearchDocument struct {
Locale string `json:"locale,omitempty"`
CreatedAtTS int64 `json:"created_at_ts"`
UpdatedAtTS int64 `json:"updated_at_ts"`
LastActivityAtTS int64 `json:"last_activity_at_ts,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
@@ -132,24 +133,32 @@ func timestamp(t time.Time) int64 {
return t.Unix()
}
func timestampPtr(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func ConversationDocument(conv model.Conversation) SearchDocument {
inboxID := conv.InboxID
doc := SearchDocument{
ID: conv.ID,
Type: ResultTypeConversation,
AccountID: conv.AccountID,
Title: fmt.Sprintf("Conversation #%d", conv.ID),
Content: conv.Labels,
Status: conv.Status,
Priority: conv.Priority,
Labels: splitLabels(conv.Labels),
AssigneeID: conv.AssigneeID,
TeamID: conv.TeamID,
InboxID: &inboxID,
ContactID: &conv.ContactID,
CreatedAtTS: timestamp(conv.CreatedAt),
UpdatedAtTS: timestamp(conv.UpdatedAt),
Data: map[string]interface{}{"conversation": conv},
ID: conv.ID,
Type: ResultTypeConversation,
AccountID: conv.AccountID,
Title: fmt.Sprintf("Conversation #%d", conv.ID),
Content: conv.Labels,
Status: conv.Status,
Priority: conv.Priority,
Labels: splitLabels(conv.Labels),
AssigneeID: conv.AssigneeID,
TeamID: conv.TeamID,
InboxID: &inboxID,
ContactID: &conv.ContactID,
CreatedAtTS: timestamp(conv.CreatedAt),
UpdatedAtTS: timestamp(conv.UpdatedAt),
LastActivityAtTS: timestampPtr(conv.LastActivityAt),
Data: map[string]interface{}{"conversation": conv},
}
doc.Snippet = conversationSnippet(&conv, "")
doc.ensureUID()
@@ -196,6 +205,7 @@ func ContactDocument(contact model.Contact) SearchDocument {
ContactHasDetails: content != "",
CreatedAtTS: timestamp(contact.CreatedAt),
UpdatedAtTS: timestamp(contact.UpdatedAt),
LastActivityAtTS: timestampPtr(contact.LastActivityAt),
Data: map[string]interface{}{"contact": contact},
}
doc.ensureUID()
+16 -4
View File
@@ -174,8 +174,8 @@ 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", "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"},
"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", "last_activity_at_ts"},
"sortableAttributes": []string{"created_at_ts", "updated_at_ts", "last_activity_at_ts", "id"},
}
resp, err := e.client.R().
SetContext(ctx).
@@ -249,15 +249,27 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
parts = append(parts, fmt.Sprintf("locale = %q", filter.ArticleLocale))
}
}
dateField := dateFilterField(docType)
if filter.AdvancedSearchEnabled && filter.DateFrom != nil {
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.DateFrom.Unix()))
parts = append(parts, fmt.Sprintf("%s >= %d", dateField, filter.DateFrom.Unix()))
}
if filter.AdvancedSearchEnabled && filter.DateTo != nil {
parts = append(parts, fmt.Sprintf("created_at_ts <= %d", filter.DateTo.Unix()))
parts = append(parts, fmt.Sprintf("%s <= %d", dateField, filter.DateTo.Unix()))
}
return strings.Join(parts, " AND ")
}
func dateFilterField(docType SearchResultType) string {
switch docType {
case ResultTypeConversation, ResultTypeContact:
return "last_activity_at_ts"
case ResultTypeArticle, ResultTypeHelpCenter:
return "updated_at_ts"
default:
return "created_at_ts"
}
}
func (e *MeiliSearchEngine) indexName(docType SearchResultType) string {
return e.indexPrefix + strings.ReplaceAll(string(docType), "_", "_") + "s"
}
+25
View File
@@ -43,15 +43,18 @@ func TestNewSearchEngine_DBFallbackRequiresRepo(t *testing.T) {
}
func TestDocumentBuildersSetStableUIDAndType(t *testing.T) {
lastActivity := int64(1772884800)
conv := makeConversation(12, 3, "open", "billing,urgent")
conv.InboxID = 7
conv.ContactID = 9
conv.LastActivityAt = &lastActivity
doc := ConversationDocument(conv)
assert.Equal(t, "3:conversation:12", doc.UID)
assert.Equal(t, ResultTypeConversation, doc.Type)
assert.Equal(t, uint(3), doc.AccountID)
assert.Equal(t, []string{"billing", "urgent"}, doc.Labels)
assert.Equal(t, lastActivity, doc.LastActivityAtTS)
}
func TestContactDocumentSetsResolvedScopeFields(t *testing.T) {
@@ -203,6 +206,28 @@ func TestMeiliSearchEngine_SearchSendsMessageRecentBaseFilter(t *testing.T) {
assert.Equal(t, "account_id = 42 AND created_at_ts >= 1772884800", requestBody["filter"])
}
func TestMeiliSearchEngine_SearchUsesReferenceTimeFilterFields(t *testing.T) {
seen := map[string]string{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
var requestBody map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
seen[r.URL.Path] = requestBody["filter"].(string)
return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil
})
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
engine.client.SetTransport(transport)
from := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeConversation, ResultTypeContact, ResultTypeArticle, ResultTypeMessage}, AdvancedSearchEnabled: true, DateFrom: &from}
_, err := engine.Search(context.Background(), 42, "hello", filter)
require.NoError(t, err)
assert.Equal(t, "account_id = 42 AND last_activity_at_ts >= 1772884800", seen["/indexes/gochat_conversations/search"])
assert.Equal(t, "account_id = 42 AND last_activity_at_ts >= 1772884800", seen["/indexes/gochat_contacts/search"])
assert.Equal(t, "account_id = 42 AND updated_at_ts >= 1772884800", seen["/indexes/gochat_articles/search"])
assert.Equal(t, "account_id = 42 AND created_at_ts >= 1772884800", seen["/indexes/gochat_messages/search"])
}
func TestMeiliSearchEngine_SearchIgnoresInaccessibleInboxParam(t *testing.T) {
var requestBody map[string]interface{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+17
View File
@@ -387,9 +387,26 @@ func (s *SearchService) withAdvancedSearchFeature(ctx context.Context, accountID
if s != nil && s.featureResolver != nil {
clone.AdvancedSearchEnabled = s.featureResolver.AccountFeatureEnabled(ctx, accountID, "advanced_search")
}
if clone.AdvancedSearchEnabled {
capAdvancedSearchDateRange(clone)
}
return clone
}
func capAdvancedSearchDateRange(filter *SearchFilter) {
now := searchNow()
maxLookback := now.Add(-90 * 24 * time.Hour)
maxFuture := now.Add(90 * 24 * time.Hour)
if filter.DateFrom != nil && filter.DateFrom.Before(maxLookback) {
capped := maxLookback
filter.DateFrom = &capped
}
if filter.DateTo != nil && filter.DateTo.After(maxFuture) {
capped := maxFuture
filter.DateTo = &capped
}
}
func (s *SearchService) withContactResolvedScope(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter {
clone := cloneSearchFilter(filter)
clone.ContactResolvedScope = true
+33 -3
View File
@@ -18,6 +18,7 @@ type mockSearchRepo struct {
conversations []model.Conversation
convCount int64
convErr error
convFilter *SearchFilter
messages []model.Message
msgCount int64
@@ -33,9 +34,10 @@ type mockSearchRepo struct {
companyCount int64
companyErr error
articles []model.Article
articleCount int64
articleErr error
articles []model.Article
articleCount int64
articleErr error
articleFilter *SearchFilter
features map[string]bool
access SearchAccess
@@ -43,6 +45,7 @@ type mockSearchRepo struct {
}
func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) {
m.convFilter = filter
return m.conversations, m.convCount, m.convErr
}
@@ -69,6 +72,7 @@ func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, qu
}
func (m *mockSearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Article, int64, error) {
m.articleFilter = filter
return m.articles, m.articleCount, m.articleErr
}
@@ -378,6 +382,32 @@ func TestSearchMessages_AppliesAdvancedSearchFeatureFlag(t *testing.T) {
assert.True(t, repo.msgFilter.AdvancedSearchEnabled)
}
func TestSearchMessages_CapsAdvancedSearchDateRange(t *testing.T) {
fixedNow := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
originalNow := searchNow
searchNow = func() time.Time { return fixedNow }
t.Cleanup(func() { searchNow = originalNow })
repo := &mockSearchRepo{
messages: []model.Message{makeMessage(10, 1, "Hello test message", "incoming")},
msgCount: 1,
features: map[string]bool{"advanced_search": true},
}
svc := NewSearchService(repo)
since := fixedNow.Add(-120 * 24 * time.Hour)
until := fixedNow.Add(120 * 24 * time.Hour)
_, _, err := svc.SearchMessages(context.Background(), 1, "test", &SearchFilter{Page: 1, PerPage: 25, DateFrom: &since, DateTo: &until})
require.NoError(t, err)
require.NotNil(t, repo.msgFilter)
if assert.NotNil(t, repo.msgFilter.DateFrom) {
assert.Equal(t, fixedNow.Add(-90*24*time.Hour), *repo.msgFilter.DateFrom)
}
if assert.NotNil(t, repo.msgFilter.DateTo) {
assert.Equal(t, fixedNow.Add(90*24*time.Hour), *repo.msgFilter.DateTo)
}
}
func TestSearchContacts_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.contacts = []model.Contact{makeContact(5, 1, "Alice", "alice@test.com", "")}