feat(search): align conversation identity search

This commit is contained in:
2026-06-07 08:05:12 +08:00
parent 9a7e455e1c
commit 450c8673d8
9 changed files with 171 additions and 42 deletions
+30 -16
View File
@@ -138,7 +138,7 @@ func NewSearchRepo(db *gorm.DB) *SearchRepo {
// SearchConversations searches conversations with advanced filters.
// This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter
// and converting it to *RepoSearchFilter internally.
// Reference: Chatwoot conversations#index with filter params (status, assignee_id, labels, etc.)
// Reference: Chatwoot SearchService#filter_conversations.
func (r *SearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Conversation, int64, error) {
repoFilter := searchFilterToRepo(filter)
return r.searchConversationsInternal(ctx, accountID, query, repoFilter)
@@ -149,25 +149,39 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID
var conversations []model.Conversation
var total int64
q := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
q := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID)
// Apply text search — ILIKE (default) or pg_trgm fuzzy match
// Chatwoot conversation search matches display_id and contact identity fields.
if query != "" {
if filter.IsTrigram() {
// pg_trgm fuzzy match: uses % operator with similarity threshold
// Requires pg_trgm extension and GIN indexes on searchable columns.
// Default similarity threshold is 0.3 (set_show_trgm).
q = q.Where(
"labels % ? OR EXISTS (SELECT 1 FROM messages WHERE messages.conversation_id = conversations.id AND messages.content % ? AND messages.account_id = ?)",
query, query, accountID,
q = q.Joins("INNER JOIN contacts ON conversations.contact_id = contacts.id").Where(
"CAST(conversations.display_id AS TEXT) % ? OR contacts.name % ? OR contacts.email % ? OR contacts.phone_number % ? OR contacts.identifier % ?",
query, query, query, query, query,
)
} else {
// ILIKE substring match (default, works without pg_trgm)
likeQuery := "%" + query + "%"
q = q.Where(
"labels ILIKE ? OR EXISTS (SELECT 1 FROM messages WHERE messages.conversation_id = conversations.id AND messages.content ILIKE ? AND messages.account_id = ?)",
likeQuery, likeQuery, accountID,
)
if r.db.Dialector.Name() == "sqlite" {
likeQuery = "%" + strings.ToLower(query) + "%"
q = q.Where(
`EXISTS (
SELECT 1 FROM contacts
WHERE contacts.id = conversations.contact_id
AND contacts.account_id = conversations.account_id
AND (LOWER(CAST(conversations.display_id AS TEXT)) LIKE ? OR LOWER(contacts.name) LIKE ? OR LOWER(contacts.email) LIKE ? OR LOWER(contacts.phone_number) LIKE ? OR LOWER(contacts.identifier) LIKE ?)
)`,
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
)
} else {
q = q.Where(
`EXISTS (
SELECT 1 FROM contacts
WHERE contacts.id = conversations.contact_id
AND contacts.account_id = conversations.account_id
AND (CAST(conversations.display_id AS TEXT) ILIKE ? OR contacts.name ILIKE ? OR contacts.email ILIKE ? OR contacts.phone_number ILIKE ? OR contacts.identifier ILIKE ?)
)`,
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
)
}
}
}
@@ -176,7 +190,7 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID
q = applyConversationFilters(q, filter)
// Apply date range filter
q = applyDateRangeFilter(q, filter, "last_activity_at", true)
q = applyDateRangeFilter(q, filter, "conversations.last_activity_at", true)
// Count
if err := q.Count(&total).Error; err != nil {
@@ -187,7 +201,7 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID
orderClause := filter.OrderClause()
// Conversations default to last_activity_at DESC NULLS LAST
if filter.SortBy == "created_at" || filter.SortBy == "" {
orderClause = "last_activity_at DESC NULLS LAST, id DESC"
orderClause = "conversations.last_activity_at DESC NULLS LAST, conversations.id DESC"
}
err := q.Offset(filter.Offset()).Limit(filter.PerPage).
+34 -5
View File
@@ -317,20 +317,49 @@ func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) {
assert.True(t, found, "expected conversation %d in results", conv.ID)
}
func TestSearchRepo_SearchConversations_LabelMatch(t *testing.T) {
skipIfSQLite(t)
func TestSearchRepo_SearchConversations_DisplayAndContactMatch(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
_ = createTestConversationForSearch(t, db, account.ID, "open", "support,billing")
_ = createTestConversationForSearch(t, db, account.ID, "resolved", "sales,marketing")
contact := createTestContactForSearch(t, db, account.ID, "Ada Lovelace", "ada@example.com", "+123")
otherContact := createTestContactForSearch(t, db, account.ID, "Grace Hopper", "grace@example.com", "+456")
match := createTestConversationForSearch(t, db, account.ID, "open", "support,billing")
displayID := uint(4242)
require.NoError(t, db.Model(match).Updates(map[string]any{"contact_id": contact.ID, "display_id": displayID}).Error)
other := createTestConversationForSearch(t, db, account.ID, "resolved", "ada-label")
require.NoError(t, db.Model(other).Update("contact_id", otherContact.ID).Error)
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "billing", filter)
results, total, err := repo.SearchConversations(context.Background(), account.ID, "ada", filter)
assert.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, match.ID, results[0].ID)
results, total, err = repo.SearchConversations(context.Background(), account.ID, "424", filter)
assert.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Len(t, results, 1)
assert.Equal(t, match.ID, results[0].ID)
}
func TestSearchRepo_SearchConversations_DoesNotMatchLabelsOrMessages(t *testing.T) {
db := setupTestDB(t)
repo := NewSearchRepo(db)
account := createTestAccountForSearch(t, db)
contact := createTestContactForSearch(t, db, account.ID, "No Match", "nomatch@example.com", "")
conv := createTestConversationForSearch(t, db, account.ID, "open", "billing")
require.NoError(t, db.Model(conv).Update("contact_id", contact.ID).Error)
_ = createTestMessageForSearch(t, db, account.ID, conv.ID, "billing message", "incoming")
filter := &search.SearchFilter{Page: 1, PerPage: 25}
results, total, err := repo.SearchConversations(context.Background(), account.ID, "billing", filter)
require.NoError(t, err)
assert.Equal(t, int64(0), total)
assert.Empty(t, results)
}
func TestSearchRepo_SearchConversations_WithStatusFilter(t *testing.T) {