feat(search): align conversation identity search
This commit is contained in:
@@ -22,12 +22,12 @@ type Conversation struct {
|
||||
TeamID *uint `gorm:"index" json:"team_id,omitempty"`
|
||||
CampaignID *uint `gorm:"index" json:"campaign_id,omitempty"`
|
||||
SlaPolicyID *uint `gorm:"index" json:"sla_policy_id,omitempty"`
|
||||
Status string `gorm:"size:50;index;default:open" json:"status"` // open, resolved, pending, snoozed
|
||||
Priority string `gorm:"size:50;default:none" json:"priority"` // none, low, medium, high, urgent
|
||||
Status string `gorm:"size:50;index;default:open" json:"status"` // open, resolved, pending, snoozed
|
||||
Priority string `gorm:"size:50;default:none" json:"priority"` // none, low, medium, high, urgent
|
||||
ChannelType string `gorm:"size:50;not null" json:"channel_type"`
|
||||
Channel string `gorm:"size:50;not null" json:"channel"` // channel identifier for routing
|
||||
Labels string `gorm:"type:text" json:"labels,omitempty"` // comma-separated or JSON label list
|
||||
SnoozedUntil *int64 `gorm:"index" json:"snoozed_until,omitempty"` // timestamp for snoozed conversations
|
||||
Channel string `gorm:"size:50;not null" json:"channel"` // channel identifier for routing
|
||||
Labels string `gorm:"type:text" json:"labels,omitempty"` // comma-separated or JSON label list
|
||||
SnoozedUntil *int64 `gorm:"index" json:"snoozed_until,omitempty"` // timestamp for snoozed conversations
|
||||
AdditionalAttributes datatypes.JSON `gorm:"type:jsonb" json:"additional_attributes,omitempty"`
|
||||
CustomAttributes datatypes.JSON `gorm:"type:jsonb" json:"custom_attributes,omitempty"`
|
||||
AgentLastSeenAt *int64 `json:"agent_last_seen_at,omitempty"`
|
||||
@@ -38,9 +38,11 @@ type Conversation struct {
|
||||
FirstReplyCreatedAt *int64 `json:"first_reply_created_at,omitempty"`
|
||||
Muted bool `gorm:"default:false" json:"muted"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"` // Chatwoot: timestamp when conversation was resolved
|
||||
ResumedAt *time.Time `json:"resumed_at,omitempty"` // Chatwoot: timestamp when conversation was reopened
|
||||
ResumedAt *time.Time `json:"resumed_at,omitempty"` // Chatwoot: timestamp when conversation was reopened
|
||||
LastMessageAt *int64 `gorm:"index" json:"last_message_at,omitempty"`
|
||||
LastNonSysMsgAt *int64 `json:"last_non_system_message_at,omitempty"`
|
||||
|
||||
Contact *Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeCreate auto-generates UUID before inserting a new Conversation record.
|
||||
@@ -51,4 +53,4 @@ func (c *Conversation) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Conversation) TableName() string { return "conversations" }
|
||||
func (Conversation) TableName() string { return "conversations" }
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -142,12 +142,13 @@ func timestampPtr(value *int64) int64 {
|
||||
|
||||
func ConversationDocument(conv model.Conversation) SearchDocument {
|
||||
inboxID := conv.InboxID
|
||||
content := conversationSearchContent(conv)
|
||||
doc := SearchDocument{
|
||||
ID: conv.ID,
|
||||
Type: ResultTypeConversation,
|
||||
AccountID: conv.AccountID,
|
||||
Title: fmt.Sprintf("Conversation #%d", conv.ID),
|
||||
Content: conv.Labels,
|
||||
Title: conversationSearchTitle(conv),
|
||||
Content: content,
|
||||
Status: conv.Status,
|
||||
Priority: conv.Priority,
|
||||
Labels: splitLabels(conv.Labels),
|
||||
@@ -165,6 +166,24 @@ func ConversationDocument(conv model.Conversation) SearchDocument {
|
||||
return doc
|
||||
}
|
||||
|
||||
func conversationSearchTitle(conv model.Conversation) string {
|
||||
if conv.DisplayID != nil && *conv.DisplayID != 0 {
|
||||
return fmt.Sprintf("Conversation #%d", *conv.DisplayID)
|
||||
}
|
||||
return fmt.Sprintf("Conversation #%d", conv.ID)
|
||||
}
|
||||
|
||||
func conversationSearchContent(conv model.Conversation) string {
|
||||
parts := make([]string, 0, 5)
|
||||
if conv.DisplayID != nil && *conv.DisplayID != 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d", *conv.DisplayID))
|
||||
}
|
||||
if conv.Contact != nil {
|
||||
parts = append(parts, conv.Contact.Name, conv.Contact.Email, conv.Contact.PhoneNumber, conv.Contact.Identifier)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func MessageDocument(msg model.Message) SearchDocument {
|
||||
inboxID := msg.InboxID
|
||||
conversationID := msg.ConversationID
|
||||
|
||||
@@ -48,6 +48,9 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) {
|
||||
conv.InboxID = 7
|
||||
conv.ContactID = 9
|
||||
conv.LastActivityAt = &lastActivity
|
||||
displayID := uint(42)
|
||||
conv.DisplayID = &displayID
|
||||
conv.Contact = &model.Contact{Name: "Ada Lovelace", Email: "ada@example.com", PhoneNumber: "+123", Identifier: "ada-id"}
|
||||
doc := ConversationDocument(conv)
|
||||
|
||||
assert.Equal(t, "3:conversation:12", doc.UID)
|
||||
@@ -55,6 +58,8 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) {
|
||||
assert.Equal(t, uint(3), doc.AccountID)
|
||||
assert.Equal(t, []string{"billing", "urgent"}, doc.Labels)
|
||||
assert.Equal(t, lastActivity, doc.LastActivityAtTS)
|
||||
assert.Equal(t, "Conversation #42", doc.Title)
|
||||
assert.Equal(t, "42 Ada Lovelace ada@example.com +123 ada-id", doc.Content)
|
||||
}
|
||||
|
||||
func TestContactDocumentSetsResolvedScopeFields(t *testing.T) {
|
||||
|
||||
@@ -41,7 +41,14 @@ func (i *DurableSearchIndexer) IndexConversation(ctx context.Context, conversati
|
||||
return nil
|
||||
}
|
||||
return i.enqueueOrIndex(ctx, "conversation", conversation.AccountID, conversation.ID, func() error {
|
||||
return i.delegate.IndexConversation(ctx, conversation)
|
||||
enriched, err := i.loadConversation(ctx, conversation.AccountID, conversation.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enriched == nil {
|
||||
return nil
|
||||
}
|
||||
return i.delegate.IndexConversation(ctx, enriched)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,11 +151,14 @@ func (i *DurableSearchIndexer) perform(ctx context.Context, job *model.Backgroun
|
||||
func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchIndexJob) error {
|
||||
switch payload.Entity {
|
||||
case "conversation":
|
||||
var item model.Conversation
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
item, err := i.loadConversation(ctx, payload.AccountID, payload.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return i.delegate.IndexConversation(ctx, &item)
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return i.delegate.IndexConversation(ctx, item)
|
||||
case "message":
|
||||
var item model.Message
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
@@ -178,6 +188,21 @@ func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchI
|
||||
}
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) loadConversation(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
||||
var item model.Conversation
|
||||
err := i.db.WithContext(ctx).
|
||||
Preload("Contact").
|
||||
Where("id = ? AND account_id = ?", id, accountID).
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, i.performDelete(ctx, searchIndexJob{Operation: "delete", Entity: "conversation", AccountID: accountID, ID: id})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) load(ctx context.Context, payload searchIndexJob, dest any) error {
|
||||
err := i.db.WithContext(ctx).Where("id = ? AND account_id = ?", payload.ID, payload.AccountID).First(dest).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
||||
@@ -11,12 +11,16 @@ import (
|
||||
)
|
||||
|
||||
type recordingDurableSearchIndexer struct {
|
||||
indexedContacts []uint
|
||||
deletedContacts []uint
|
||||
err error
|
||||
indexedConversations []model.Conversation
|
||||
indexedContacts []uint
|
||||
deletedContacts []uint
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingDurableSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error {
|
||||
if conversation != nil {
|
||||
r.indexedConversations = append(r.indexedConversations, *conversation)
|
||||
}
|
||||
return r.err
|
||||
}
|
||||
|
||||
@@ -91,6 +95,33 @@ func TestDurableSearchIndexerQueuesAndReplaysContactIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSearchIndexerPreloadsConversationContact(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
account := createTestAccount(t, db)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Ada Lovelace", Email: "ada@example.com"}
|
||||
if err := db.Create(contact).Error; err != nil {
|
||||
t.Fatalf("create contact: %v", err)
|
||||
}
|
||||
conversation := &model.Conversation{AccountID: account.ID, ContactID: contact.ID, InboxID: 1, ChannelType: "web_widget", Channel: "web_widget", Status: "open"}
|
||||
if err := db.Create(conversation).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
delegate := &recordingDurableSearchIndexer{}
|
||||
indexer := NewDurableSearchIndexer(db, nil, delegate)
|
||||
|
||||
if err := indexer.IndexConversation(context.Background(), conversation); err != nil {
|
||||
t.Fatalf("index conversation: %v", err)
|
||||
}
|
||||
|
||||
if len(delegate.indexedConversations) != 1 {
|
||||
t.Fatalf("expected one indexed conversation, got %#v", delegate.indexedConversations)
|
||||
}
|
||||
indexed := delegate.indexedConversations[0]
|
||||
if indexed.Contact == nil || indexed.Contact.Name != "Ada Lovelace" || indexed.Contact.Email != "ada@example.com" {
|
||||
t.Fatalf("expected preloaded contact in indexed conversation, got %#v", indexed.Contact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSearchIndexerRetriesDelegateFailure(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
account := createTestAccount(t, db)
|
||||
|
||||
Reference in New Issue
Block a user