feat(search): scope message recency

This commit is contained in:
2026-06-07 07:22:42 +08:00
parent 4bde740c80
commit 112ae43955
8 changed files with 101 additions and 10 deletions
+10
View File
@@ -33,6 +33,7 @@ type RepoSearchFilter struct {
Private *bool
EnforceInboxAccess bool
AccessibleInboxIDs []uint
MessageCreatedAfter *time.Time
DateFrom *time.Time
DateTo *time.Time
SortBy string
@@ -107,6 +108,7 @@ func searchFilterToRepo(f *search.SearchFilter) *RepoSearchFilter {
Private: f.Private,
EnforceInboxAccess: f.EnforceInboxAccess,
AccessibleInboxIDs: append([]uint(nil), f.AccessibleInboxIDs...),
MessageCreatedAfter: f.MessageCreatedAfter,
DateFrom: f.DateFrom,
DateTo: f.DateTo,
SortBy: f.SortBy,
@@ -207,6 +209,7 @@ func (r *SearchRepo) searchMessagesInternal(ctx context.Context, accountID uint,
var total int64
q := r.db.WithContext(ctx).Model(&model.Message{}).Where("account_id = ?", accountID)
q = applyMessageRecentBase(q, filter)
// Text search on message content — ILIKE or pg_trgm fuzzy match
if query != "" {
@@ -515,6 +518,13 @@ func applySearchInboxAccess(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
return q.Where("inbox_id IN ?", filter.AccessibleInboxIDs)
}
func applyMessageRecentBase(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB {
if filter == nil || filter.MessageCreatedAfter == nil {
return q
}
return q.Where("created_at >= ?", *filter.MessageCreatedAfter)
}
func searchInboxIDAllowed(filter *RepoSearchFilter, inboxID uint) bool {
if filter == nil || !filter.EnforceInboxAccess {
return true
+22
View File
@@ -4,6 +4,7 @@ import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -245,6 +246,27 @@ func TestSearchRepo_SearchMessages_InaccessibleInboxParamFallsBackToAccessibleBa
assert.Equal(t, visibleMsg.ID, results[0].ID)
}
func TestSearchRepo_SearchMessages_MessageBaseExcludesOldMessages(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
conv := createTestConversationForSearch(t, db, account.ID, "open", "")
cutoff := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
recentMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "recent message", "incoming")
require.NoError(t, db.Model(recentMsg).Update("created_at", cutoff.Add(time.Hour)).Error)
oldMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "old message", "incoming")
require.NoError(t, db.Model(oldMsg).Update("created_at", cutoff.Add(-time.Hour)).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25, MessageCreatedAfter: &cutoff}
results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter)
require.NoError(t, err)
assert.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, recentMsg.ID, results[0].ID)
}
// ========== SearchConversations DB tests (PG only for ILIKE) ==========
func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) {
+3
View File
@@ -207,6 +207,9 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
}
if docType == ResultTypeMessage {
if filter.MessageCreatedAfter != nil {
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.MessageCreatedAfter.Unix()))
}
if filter.MessageType != "" {
parts = append(parts, fmt.Sprintf("message_type = %q", filter.MessageType))
}
+19
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -164,6 +165,24 @@ func TestMeiliSearchEngine_SearchSendsInboxAccessFilter(t *testing.T) {
assert.Equal(t, "account_id = 42 AND (inbox_id = 3 OR inbox_id = 5)", requestBody["filter"])
}
func TestMeiliSearchEngine_SearchSendsMessageRecentBaseFilter(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)
cutoff := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, MessageCreatedAfter: &cutoff}
_, err := engine.Search(context.Background(), 42, "hello", filter)
require.NoError(t, err)
assert.Equal(t, "account_id = 42 AND created_at_ts >= 1772884800", requestBody["filter"])
}
func TestMeiliSearchEngine_SearchIgnoresInaccessibleInboxParam(t *testing.T) {
var requestBody map[string]interface{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+4 -3
View File
@@ -51,9 +51,10 @@ type SearchFilter struct {
// Chatwoot SearchService scopes conversation/message results to the current
// user's assigned inboxes unless the user can access every inbox.
CurrentUserID *uint `form:"-"`
EnforceInboxAccess bool `form:"-"`
AccessibleInboxIDs []uint `form:"-"`
CurrentUserID *uint `form:"-"`
EnforceInboxAccess bool `form:"-"`
AccessibleInboxIDs []uint `form:"-"`
MessageCreatedAfter *time.Time `form:"-"`
// Article-specific filters (Knowledge Base)
PortalID *uint `form:"portal_id"` // portal to search within
+15 -3
View File
@@ -4,11 +4,14 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
var searchNow = time.Now
// SearchService provides unified global search across conversations, messages,
// and contacts with advanced filtering and sorting.
// Reference: Chatwoot GlobalSearchService — cross-entity search with filter params.
@@ -85,10 +88,10 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
}
if s.engine != nil {
filter = s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter))
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter)))
return s.engine.Search(ctx, accountID, query, filter)
}
filter = s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter))
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, filter)))
var allResults []SearchResult
byType := map[string]int64{}
@@ -241,7 +244,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.withSearchAccess(ctx, accountID, filter)
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, filter))
if s.engine != nil {
return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeMessage)
}
@@ -367,6 +370,15 @@ func cloneSearchFilter(filter *SearchFilter) *SearchFilter {
return &clone
}
func (s *SearchService) withMessageRecentBase(filter *SearchFilter) *SearchFilter {
clone := cloneSearchFilter(filter)
cutoff := searchNow().AddDate(0, -3, 0)
if clone.MessageCreatedAfter == nil || clone.MessageCreatedAfter.Before(cutoff) {
clone.MessageCreatedAfter = &cutoff
}
return clone
}
func (s *SearchService) withContactResolvedScope(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter {
clone := cloneSearchFilter(filter)
clone.ContactResolvedScope = true
+21
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -343,6 +344,26 @@ func TestSearchMessages_AppliesCurrentUserInboxAccess(t *testing.T) {
assert.Equal(t, []uint{3, 5}, repo.msgFilter.AccessibleInboxIDs)
}
func TestSearchMessages_AppliesThreeMonthMessageBase(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,
}
svc := NewSearchService(repo)
_, _, err := svc.SearchMessages(context.Background(), 1, "test", &SearchFilter{Page: 1, PerPage: 25})
require.NoError(t, err)
require.NotNil(t, repo.msgFilter)
if assert.NotNil(t, repo.msgFilter.MessageCreatedAfter) {
assert.Equal(t, fixedNow.AddDate(0, -3, 0), *repo.msgFilter.MessageCreatedAfter)
}
}
func TestSearchContacts_ConvenienceMethod(t *testing.T) {
repo := &mockSearchRepo{}
repo.contacts = []model.Contact{makeContact(5, 1, "Alice", "alice@test.com", "")}