package search import ( "context" "fmt" "strings" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // SearchService provides unified global search across conversations, messages, // and contacts with advanced filtering and sorting. // Reference: Chatwoot GlobalSearchService — cross-entity search with filter params. type SearchService struct { searchRepo SearchRepoInterface engine SearchEngine } // NewSearchService creates a new SearchService. func NewSearchService(searchRepo SearchRepoInterface) *SearchService { return &SearchService{searchRepo: searchRepo} } // NewSearchServiceWithEngine creates a SearchService backed by an explicit // SearchEngine. The repository remains available for db fallback and legacy tests. func NewSearchServiceWithEngine(engine SearchEngine, fallbackRepo SearchRepoInterface) *SearchService { return &SearchService{searchRepo: fallbackRepo, engine: engine} } // GlobalSearch performs a unified search across all searchable entity types // (conversations, messages, contacts) based on the provided query and filters. // Returns a SearchResponse with results grouped by type and pagination metadata. // Reference: Chatwoot GlobalSearchService — searches across conversations, messages, contacts func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) { query = strings.TrimSpace(query) if filter == nil { filter = &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder} } if query == "" && len(filter.Status) == 0 && len(filter.Priority) == 0 && filter.AssigneeID == nil && filter.TeamID == nil && filter.InboxID == nil && len(filter.Labels) == 0 && filter.DateFrom == nil && filter.DateTo == nil && filter.MessageType == "" && filter.SenderType == "" && filter.SenderID == nil && filter.ContentType == "" && filter.Private == nil && filter.ContactSource == "" && filter.PortalID == nil && filter.ArticleStatus == "" && filter.ArticleLocale == "" { // No query and no filters — return empty results return &SearchResponse{ Results: []SearchResult{}, TotalCount: 0, ByType: map[string]int64{}, Page: filter.Page, PerPage: filter.PerPage, Query: query, }, nil } if s.engine != nil { return s.engine.Search(ctx, accountID, query, filter) } var allResults []SearchResult byType := map[string]int64{} var totalCount int64 // Search conversations if filter.ShouldSearchType(ResultTypeConversation) { conversations, convCount, err := s.searchRepo.SearchConversations(ctx, accountID, query, filter) if err != nil { applogger.L().Warnf("search conversations error: %v", err) } else { byType["conversation"] = convCount totalCount += convCount for _, conv := range conversations { allResults = append(allResults, SearchResult{ Type: ResultTypeConversation, ID: conv.ID, AccountID: conv.AccountID, Snippet: conversationSnippet(&conv, query), Score: conversationScore(&conv, query), Data: conv, }) } } } // Search messages if filter.ShouldSearchType(ResultTypeMessage) { messages, msgCount, err := s.searchRepo.SearchMessages(ctx, accountID, query, filter) if err != nil { applogger.L().Warnf("search messages error: %v", err) } else { byType["message"] = msgCount totalCount += msgCount for _, msg := range messages { allResults = append(allResults, SearchResult{ Type: ResultTypeMessage, ID: msg.ID, AccountID: msg.AccountID, Snippet: messageSnippet(&msg, query), Score: messageScore(&msg, query), Data: msg, }) } } } // Search contacts if filter.ShouldSearchType(ResultTypeContact) { contacts, contactCount, err := s.searchRepo.SearchContacts(ctx, accountID, query, filter) if err != nil { applogger.L().Warnf("search contacts error: %v", err) } else { byType["contact"] = contactCount totalCount += contactCount for _, contact := range contacts { allResults = append(allResults, SearchResult{ Type: ResultTypeContact, ID: contact.ID, AccountID: contact.AccountID, Snippet: contactSnippet(&contact, query), Score: contactScore(&contact, query), Data: contact, }) } } } // Search companies if filter.ShouldSearchType(ResultTypeCompany) { companies, companyCount, err := s.searchRepo.SearchCompanies(ctx, accountID, query, filter) if err != nil { applogger.L().Warnf("search companies error: %v", err) } else { byType["company"] = companyCount totalCount += companyCount for _, company := range companies { allResults = append(allResults, SearchResult{ Type: ResultTypeCompany, ID: company.ID, AccountID: company.AccountID, Snippet: company.Name, Score: 1, Data: company, }) } } } // Search articles (Knowledge Base) if filter.ShouldSearchType(ResultTypeArticle) { articles, articleCount, err := s.searchRepo.SearchArticles(ctx, accountID, query, filter) if err != nil { applogger.L().Warnf("search articles error: %v", err) } else { byType["article"] = articleCount totalCount += articleCount for _, article := range articles { allResults = append(allResults, SearchResult{ Type: ResultTypeArticle, ID: article.ID, AccountID: article.AccountID, Snippet: articleSnippet(&article, query), Score: articleScore(&article, query), Data: article, }) } } } // Sort combined results by score (descending), then by ID for stable ordering sortResultsByScore(allResults) return &SearchResponse{ Results: allResults, TotalCount: totalCount, ByType: byType, Page: filter.Page, PerPage: filter.PerPage, Query: query, }, nil } // SearchConversations performs a filtered conversation search. // Convenience method for conversation-only search with full filter support. func (s *SearchService) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) { if s.engine != nil { return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeConversation) } conversations, total, err := s.searchRepo.SearchConversations(ctx, accountID, query, filter) if err != nil { return nil, 0, fmt.Errorf("search conversations: %w", err) } results := make([]SearchResult, len(conversations)) for i, conv := range conversations { results[i] = SearchResult{ Type: ResultTypeConversation, ID: conv.ID, AccountID: conv.AccountID, Snippet: conversationSnippet(&conv, query), Score: conversationScore(&conv, query), Data: conv, } } return results, total, nil } // 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) { if s.engine != nil { return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeMessage) } messages, total, err := s.searchRepo.SearchMessages(ctx, accountID, query, filter) if err != nil { return nil, 0, fmt.Errorf("search messages: %w", err) } results := make([]SearchResult, len(messages)) for i, msg := range messages { results[i] = SearchResult{ Type: ResultTypeMessage, ID: msg.ID, AccountID: msg.AccountID, Snippet: messageSnippet(&msg, query), Score: messageScore(&msg, query), Data: msg, } } return results, total, nil } // SearchContacts performs a filtered contact search. // Convenience method for contact-only search with full filter support. func (s *SearchService) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) { if s.engine != nil { return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeContact) } contacts, total, err := s.searchRepo.SearchContacts(ctx, accountID, query, filter) if err != nil { return nil, 0, fmt.Errorf("search contacts: %w", err) } results := make([]SearchResult, len(contacts)) for i, contact := range contacts { results[i] = SearchResult{ Type: ResultTypeContact, ID: contact.ID, AccountID: contact.AccountID, Snippet: contactSnippet(&contact, query), Score: contactScore(&contact, query), Data: contact, } } return results, total, nil } // SearchCompanies performs a filtered company search. func (s *SearchService) SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) { if s.engine != nil { return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeCompany) } companies, total, err := s.searchRepo.SearchCompanies(ctx, accountID, query, filter) if err != nil { return nil, 0, fmt.Errorf("search companies: %w", err) } results := make([]SearchResult, len(companies)) for i, company := range companies { results[i] = SearchResult{ Type: ResultTypeCompany, ID: company.ID, AccountID: company.AccountID, Snippet: company.Name, Score: 1, Data: company, } } return results, total, nil } // SearchArticles performs a filtered knowledge base article search. // Convenience method for article-only search with full filter support. func (s *SearchService) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) { if s.engine != nil { return s.searchWithEngineForType(ctx, accountID, query, filter, ResultTypeArticle) } articles, total, err := s.searchRepo.SearchArticles(ctx, accountID, query, filter) if err != nil { return nil, 0, fmt.Errorf("search articles: %w", err) } results := make([]SearchResult, len(articles)) for i, article := range articles { results[i] = SearchResult{ Type: ResultTypeArticle, ID: article.ID, AccountID: article.AccountID, Snippet: articleSnippet(&article, query), Score: articleScore(&article, query), Data: article, } } return results, total, nil } func (s *SearchService) searchWithEngineForType(ctx context.Context, accountID uint, query string, filter *SearchFilter, resultType SearchResultType) ([]SearchResult, int64, error) { engineFilter := cloneSearchFilter(filter) engineFilter.Types = []SearchResultType{resultType} resp, err := s.engine.Search(ctx, accountID, query, engineFilter) if err != nil { return nil, 0, err } return resp.Results, resp.ByType[string(resultType)], nil } func cloneSearchFilter(filter *SearchFilter) *SearchFilter { if filter == nil { return &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder} } clone := *filter clone.Types = append([]SearchResultType(nil), filter.Types...) clone.Status = append([]string(nil), filter.Status...) clone.Priority = append([]string(nil), filter.Priority...) clone.Labels = append([]string(nil), filter.Labels...) return &clone } func (s *SearchService) IndexDocument(ctx context.Context, doc SearchDocument) error { if s.engine == nil { return nil } return s.engine.IndexDocument(ctx, doc) } func (s *SearchService) IndexConversation(ctx context.Context, conversation *model.Conversation) error { if conversation == nil { return nil } return s.IndexDocument(ctx, ConversationDocument(*conversation)) } func (s *SearchService) DeleteConversation(ctx context.Context, accountID uint, id uint) error { return s.DeleteDocument(ctx, ResultTypeConversation, accountID, id) } func (s *SearchService) IndexMessage(ctx context.Context, message *model.Message) error { if message == nil { return nil } return s.IndexDocument(ctx, MessageDocument(*message)) } func (s *SearchService) DeleteMessage(ctx context.Context, accountID uint, id uint) error { return s.DeleteDocument(ctx, ResultTypeMessage, accountID, id) } func (s *SearchService) IndexContact(ctx context.Context, contact *model.Contact) error { if contact == nil { return nil } return s.IndexDocument(ctx, ContactDocument(*contact)) } func (s *SearchService) DeleteContact(ctx context.Context, accountID uint, id uint) error { return s.DeleteDocument(ctx, ResultTypeContact, accountID, id) } func (s *SearchService) IndexCompany(ctx context.Context, company *model.Company) error { if company == nil { return nil } return s.IndexDocument(ctx, CompanyDocument(*company)) } func (s *SearchService) DeleteCompany(ctx context.Context, accountID uint, id uint) error { return s.DeleteDocument(ctx, ResultTypeCompany, accountID, id) } func (s *SearchService) IndexArticle(ctx context.Context, article *model.Article) error { if article == nil { return nil } return s.IndexDocument(ctx, ArticleDocument(*article)) } func (s *SearchService) DeleteArticle(ctx context.Context, accountID uint, id uint) error { return s.DeleteDocument(ctx, ResultTypeArticle, accountID, id) } func (s *SearchService) IndexBatch(ctx context.Context, docs []SearchDocument) error { if s.engine == nil { return nil } return s.engine.IndexBatch(ctx, docs) } func (s *SearchService) DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error { if s.engine == nil { return nil } return s.engine.DeleteDocument(ctx, docType, accountID, id) } // --- Snippet and scoring helpers --- // conversationSnippet generates a short display snippet for a conversation result. func conversationSnippet(conv *model.Conversation, query string) string { if query == "" { return fmt.Sprintf("Conversation #%d — %s", conv.ID, conv.Status) } // Labels is stored as a text field; try to match against it if conv.Labels != "" { if strings.Contains(strings.ToLower(conv.Labels), strings.ToLower(query)) { return fmt.Sprintf("Labels containing: %s", query) } } return fmt.Sprintf("Conversation #%d — %s", conv.ID, conv.Status) } // conversationScore computes a relevance score for a conversation result. // Higher score for: status=open, label match, recent activity. func conversationScore(conv *model.Conversation, query string) float64 { score := 1.0 if conv.Status == "open" { score += 2.0 } if query != "" && conv.Labels != "" { lowerLabels := strings.ToLower(conv.Labels) lowerQuery := strings.ToLower(query) if strings.Contains(lowerLabels, lowerQuery) { score += 2.0 // label contains query } } return score } // messageSnippet generates a short display snippet for a message result. func messageSnippet(msg *model.Message, query string) string { content := msg.Content maxLen := 120 if len(content) > maxLen { // Try to center snippet around the query match if query != "" && len(query) < maxLen { idx := strings.Index(strings.ToLower(content), strings.ToLower(query)) if idx > 0 { start := idx - 30 if start < 0 { start = 0 } end := idx + len(query) + 60 if end > len(content) { end = len(content) } return "..." + content[start:end] + "..." } } return content[:maxLen] + "..." } return content } // messageScore computes a relevance score for a message result. // Higher score for: exact content match, recent messages, outgoing messages. func messageScore(msg *model.Message, query string) float64 { score := 1.0 if query != "" { if strings.Contains(strings.ToLower(msg.Content), strings.ToLower(query)) { score += 3.0 } if strings.EqualFold(msg.Content, query) { score += 5.0 // exact match } } if msg.MessageType == "outgoing" { score += 0.5 } return score } // contactSnippet generates a short display snippet for a contact result. func contactSnippet(contact *model.Contact, query string) string { parts := []string{} if contact.Name != "" { parts = append(parts, contact.Name) } if contact.Email != "" { parts = append(parts, contact.Email) } if contact.PhoneNumber != "" { parts = append(parts, contact.PhoneNumber) } if len(parts) == 0 { return fmt.Sprintf("Contact #%d", contact.ID) } return strings.Join(parts, " — ") } // contactScore computes a relevance score for a contact result. // Higher score for: exact name/email match. func contactScore(contact *model.Contact, query string) float64 { score := 1.0 if query != "" { qLower := strings.ToLower(query) if strings.EqualFold(contact.Name, query) { score += 5.0 } else if strings.Contains(strings.ToLower(contact.Name), qLower) { score += 3.0 } if strings.EqualFold(contact.Email, query) { score += 5.0 } else if strings.Contains(strings.ToLower(contact.Email), qLower) { score += 2.0 } if strings.Contains(strings.ToLower(contact.PhoneNumber), qLower) { score += 2.0 } } return score } // articleSnippet generates a short display snippet for an article result. func articleSnippet(article *model.Article, query string) string { // Prefer description as snippet (shorter, more display-friendly) if article.Description != "" { desc := article.Description maxLen := 120 if len(desc) > maxLen { if query != "" && len(query) < maxLen { idx := strings.Index(strings.ToLower(desc), strings.ToLower(query)) if idx > 0 { start := idx - 30 if start < 0 { start = 0 } end := idx + len(query) + 60 if end > len(desc) { end = len(desc) } return "..." + desc[start:end] + "..." } } return desc[:maxLen] + "..." } return desc } // Fallback to title if article.Title != "" { return article.Title } return fmt.Sprintf("Article #%d", article.ID) } // articleScore computes a relevance score for an article result. // Higher score for: title match, description match, published status. func articleScore(article *model.Article, query string) float64 { score := 1.0 if query != "" { qLower := strings.ToLower(query) // Title match is most valuable if strings.EqualFold(article.Title, query) { score += 5.0 // exact title match } else if strings.Contains(strings.ToLower(article.Title), qLower) { score += 3.0 // title contains query } // Description match if strings.Contains(strings.ToLower(article.Description), qLower) { score += 2.0 } // Content match (least weight, but still relevant) if strings.Contains(strings.ToLower(article.Content), qLower) { score += 1.0 } } // Published articles are more relevant than drafts if article.Status == "published" { score += 1.5 } return score } // sortResultsByScore sorts search results by score descending. func sortResultsByScore(results []SearchResult) { // Simple insertion sort — result sets are typically small (<=100 items) for i := 1; i < len(results); i++ { j := i for j > 0 && results[j].Score > results[j-1].Score { results[j], results[j-1] = results[j-1], results[j] j-- } } }