594 lines
19 KiB
Go
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--
|
|
}
|
|
}
|
|
}
|