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