Files
gochat/internal/search/search_service.go
T

594 lines
19 KiB
Go

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--
}
}
}