From 202da194e1ceee520bbdf7b715d2d4704f445408 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 04:16:49 +0800 Subject: [PATCH] feat(crm): route crm search through meilisearch --- internal/app/bootstrap.go | 2 + .../handler/api/v1/search_handler_test.go | 22 +++++--- internal/repository/search_repo.go | 34 ++++++++++++- internal/search/search_repo_interface.go | 3 +- internal/search/search_service.go | 45 +++++++++++++++++ internal/search/search_service_test.go | 50 +++++++++++++++---- internal/service/company_service.go | 32 ++++++++++++ internal/service/company_service_test.go | 37 ++++++++++++++ internal/service/contact_service.go | 40 +++++++++++++++ internal/service/contact_service_g3_test.go | 40 +++++++++++++++ internal/service/search_indexer.go | 31 ++++++++++++ 11 files changed, 318 insertions(+), 18 deletions(-) diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index b10a17cd..7e65f266 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -636,7 +636,9 @@ func Bootstrap(env string) (*App, error) { conversationService.SetSearchIndexer(searchService) messageService.SetSearchIndexer(searchService) contactService.SetSearchIndexer(searchService) + contactService.SetSearchReader(searchService) companyService.SetSearchIndexer(searchService) + companyService.SetSearchReader(searchService) articleService.SetSearchIndexer(searchService) // Custom attribute definition + custom filter + custom attribute value services diff --git a/internal/handler/api/v1/search_handler_test.go b/internal/handler/api/v1/search_handler_test.go index 9b513908..2bc7a523 100644 --- a/internal/handler/api/v1/search_handler_test.go +++ b/internal/handler/api/v1/search_handler_test.go @@ -24,11 +24,15 @@ type mockSearchRepo struct { msgTotal int64 msgErr error - contacts []model.Contact + contacts []model.Contact contactTotal int64 contactErr error - articles []model.Article + companies []model.Company + companyTotal int64 + companyErr error + + articles []model.Article articleTotal int64 articleErr error } @@ -45,6 +49,10 @@ func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, que return m.contacts, m.contactTotal, m.contactErr } +func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Company, int64, error) { + return m.companies, m.companyTotal, m.companyErr +} + func (m *mockSearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Article, int64, error) { return m.articles, m.articleTotal, m.articleErr } @@ -263,8 +271,8 @@ func TestSearchHandler_SearchMessages_ServiceError(t *testing.T) { func TestSearchHandler_SearchContacts_Success(t *testing.T) { repo := &mockSearchRepo{ - contacts: []model.Contact{makeContact(1, "Alice")}, - contactTotal: 1, + contacts: []model.Contact{makeContact(1, "Alice")}, + contactTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) @@ -315,8 +323,8 @@ func TestSearchHandler_SearchContacts_ServiceError(t *testing.T) { func TestSearchHandler_SearchArticles_Success(t *testing.T) { repo := &mockSearchRepo{ - articles: []model.Article{makeArticle(1, 1, "FAQ Guide", "desc", "content", "published")}, - articleTotal: 1, + articles: []model.Article{makeArticle(1, 1, "FAQ Guide", "desc", "content", "published")}, + articleTotal: 1, } svc := search.NewSearchService(repo) handler := NewSearchHandler(svc) @@ -361,4 +369,4 @@ func TestSearchHandler_SearchArticles_ServiceError(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) -} \ No newline at end of file +} diff --git a/internal/repository/search_repo.go b/internal/repository/search_repo.go index 8d667bcf..415faf75 100644 --- a/internal/repository/search_repo.go +++ b/internal/repository/search_repo.go @@ -280,6 +280,38 @@ func (r *SearchRepo) searchContactsInternal(ctx context.Context, accountID uint, return contacts, total, err } +// SearchCompanies searches companies with advanced filters. +func (r *SearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Company, int64, error) { + repoFilter := searchFilterToRepo(filter) + return r.searchCompaniesInternal(ctx, accountID, query, repoFilter) +} + +func (r *SearchRepo) searchCompaniesInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Company, int64, error) { + var companies []model.Company + var total int64 + + q := r.db.WithContext(ctx).Model(&model.Company{}).Where("account_id = ?", accountID) + if query != "" { + if filter.IsTrigram() { + q = q.Where("name % ? OR description % ? OR domain % ? OR website_url % ?", query, query, query, query) + } else { + likeQuery := "%" + query + "%" + q = q.Where("LOWER(name) LIKE LOWER(?) OR LOWER(description) LIKE LOWER(?) OR LOWER(domain) LIKE LOWER(?) OR LOWER(website_url) LIKE LOWER(?)", + likeQuery, likeQuery, likeQuery, likeQuery) + } + } + + q = applyDateRangeFilter(q, filter) + if err := q.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("search companies count: %w", err) + } + + err := q.Offset(filter.Offset()).Limit(filter.PerPage). + Order(filter.OrderClause() + ", id DESC"). + Find(&companies).Error + return companies, total, err +} + // SearchArticles searches knowledge base articles with advanced filters. // This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter // and converting it to *RepoSearchFilter internally. @@ -470,4 +502,4 @@ func applyDateRangeFilter(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB { } return q -} \ No newline at end of file +} diff --git a/internal/search/search_repo_interface.go b/internal/search/search_repo_interface.go index 274d485e..b0caa3db 100644 --- a/internal/search/search_repo_interface.go +++ b/internal/search/search_repo_interface.go @@ -14,5 +14,6 @@ type SearchRepoInterface interface { SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) SearchMessages(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Message, int64, error) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Contact, int64, error) + SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Company, int64, error) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Article, int64, error) -} \ No newline at end of file +} diff --git a/internal/search/search_service.go b/internal/search/search_service.go index 3f21e08d..6a9e8d54 100644 --- a/internal/search/search_service.go +++ b/internal/search/search_service.go @@ -126,6 +126,27 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query } } + // 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) @@ -235,6 +256,30 @@ func (s *SearchService) SearchContacts(ctx context.Context, accountID uint, quer 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) { diff --git a/internal/search/search_service_test.go b/internal/search/search_service_test.go index 67527a27..006d5fbd 100644 --- a/internal/search/search_service_test.go +++ b/internal/search/search_service_test.go @@ -15,20 +15,24 @@ import ( // mockSearchRepo implements SearchRepoInterface for testing. type mockSearchRepo struct { conversations []model.Conversation - convCount int64 - convErr error + convCount int64 + convErr error - messages []model.Message - msgCount int64 - msgErr error + messages []model.Message + msgCount int64 + msgErr error contacts []model.Contact contactCount int64 contactErr error - articles []model.Article - articleCount int64 - articleErr error + companies []model.Company + companyCount int64 + companyErr error + + articles []model.Article + articleCount int64 + articleErr error } func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) { @@ -43,6 +47,10 @@ func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, que return m.contacts, m.contactCount, m.contactErr } +func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Company, int64, error) { + return m.companies, m.companyCount, m.companyErr +} + func (m *mockSearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Article, int64, error) { return m.articles, m.articleCount, m.articleErr } @@ -75,6 +83,15 @@ func makeContact(id uint, accountID uint, name string, email string, phone strin } } +func makeCompany(id uint, accountID uint, name string, domain string) model.Company { + return model.Company{ + AccountID: accountID, + ID: id, + Name: name, + Domain: domain, + } +} + func makeArticle(id uint, accountID uint, title string, description string, content string, status string) model.Article { return model.Article{ Base: model.Base{ID: id}, @@ -277,6 +294,21 @@ func TestSearchContacts_ConvenienceMethod(t *testing.T) { assert.Equal(t, ResultTypeContact, results[0].Type) } +func TestSearchCompanies_ConvenienceMethod(t *testing.T) { + repo := &mockSearchRepo{} + repo.companies = []model.Company{makeCompany(7, 1, "Acme", "acme.example")} + repo.companyCount = 1 + + svc := NewSearchService(repo) + filter := &SearchFilter{Page: 1, PerPage: 25} + + results, total, err := svc.SearchCompanies(context.Background(), 1, "acme", filter) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Len(t, results, 1) + assert.Equal(t, ResultTypeCompany, results[0].Type) +} + func TestSearchArticles_ConvenienceMethod(t *testing.T) { repo := &mockSearchRepo{} repo.articles = []model.Article{makeArticle(1, 1, "FAQ", "Common questions", "Details here", "published")} @@ -555,4 +587,4 @@ func TestSortResultsByScore_StableOrder(t *testing.T) { assert.Equal(t, uint(1), results[0].ID) assert.Equal(t, uint(2), results[1].ID) assert.Equal(t, uint(3), results[2].ID) -} \ No newline at end of file +} diff --git a/internal/service/company_service.go b/internal/service/company_service.go index 2d7d1886..d3d46b6b 100644 --- a/internal/service/company_service.go +++ b/internal/service/company_service.go @@ -22,6 +22,7 @@ type CompanyService struct { contactRepo *repository.ContactRepo conversationRepo *repository.ConversationRepo searchIndexer SearchIndexer + searchReader CompanySearchReader } // NewCompanyService creates a new Company service. @@ -37,6 +38,10 @@ func (s *CompanyService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer } +func (s *CompanyService) SetSearchReader(reader CompanySearchReader) { + s.searchReader = reader +} + func (s *CompanyService) DB() *gorm.DB { if s == nil || s.companyRepo == nil { return nil @@ -102,9 +107,36 @@ func (s *CompanyService) Search(ctx context.Context, accountID uint, query strin if query == "" { return s.companyRepo.ListByAccount(ctx, accountID, offset, limit, sort) } + if s.searchReader != nil { + filter := serviceSearchFilter(offset, limit, sort, searchMode, search.ResultTypeCompany) + results, total, err := s.searchReader.SearchCompanies(ctx, accountID, query, filter) + if err != nil { + return nil, 0, err + } + companies, err := s.companiesFromSearchResults(ctx, accountID, results) + if err != nil { + return nil, 0, err + } + return companies, total, nil + } return s.companyRepo.Search(ctx, accountID, query, offset, limit, sort, searchMode) } +func (s *CompanyService) companiesFromSearchResults(ctx context.Context, accountID uint, results []search.SearchResult) ([]model.Company, error) { + companies := make([]model.Company, 0, len(results)) + for _, result := range results { + if result.ID == 0 || result.AccountID != accountID { + continue + } + company, err := s.companyRepo.FindByIDAndAccount(ctx, result.ID, accountID) + if err != nil { + continue + } + companies = append(companies, *company) + } + return companies, nil +} + // Get retrieves a single company by ID scoped to an account. func (s *CompanyService) Get(ctx context.Context, id, accountID uint) (*model.Company, error) { company, err := s.companyRepo.FindByIDAndAccount(ctx, id, accountID) diff --git a/internal/service/company_service_test.go b/internal/service/company_service_test.go index 47650b9f..e535e210 100644 --- a/internal/service/company_service_test.go +++ b/internal/service/company_service_test.go @@ -15,6 +15,19 @@ import ( "github.com/gochat/gochat/internal/search" ) +type mockCompanySearchReader struct { + results []search.SearchResult + total int64 + filter *search.SearchFilter + query string +} + +func (m *mockCompanySearchReader) SearchCompanies(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]search.SearchResult, int64, error) { + m.query = query + m.filter = filter + return m.results, m.total, nil +} + // ========== Test Setup ========== func setupCompanyServiceTest(t *testing.T) (*gorm.DB, *repository.CompanyRepo, *repository.ContactRepo, *repository.ConversationRepo, *CompanyService) { @@ -264,6 +277,30 @@ func TestCompanyService_Search_EmptyQuery(t *testing.T) { assert.Len(t, companies, 1) } +func TestCompanyService_Search_UsesSearchReader(t *testing.T) { + db, _, _, _, svc := setupCompanyServiceTest(t) + account := createTestAccount(t, db) + company := createTestCompanySvc(t, db, account.ID, "Meili Corp", "meili.example.com") + createTestCompanySvc(t, db, account.ID, "DB Corp", "db.example.com") + + reader := &mockCompanySearchReader{ + results: []search.SearchResult{{Type: search.ResultTypeCompany, ID: company.ID, AccountID: account.ID}}, + total: 1, + } + svc.SetSearchReader(reader) + + companies, total, err := svc.Search(context.Background(), account.ID, "meili", 0, 10, "", search.SearchModeILike) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + require.Len(t, companies, 1) + assert.Equal(t, company.ID, companies[0].ID) + assert.Equal(t, "meili", reader.query) + require.NotNil(t, reader.filter) + assert.Equal(t, []search.SearchResultType{search.ResultTypeCompany}, reader.filter.Types) + assert.Equal(t, 1, reader.filter.Page) + assert.Equal(t, 10, reader.filter.PerPage) +} + // ========== ListContacts ========== func TestCompanyService_ListContacts(t *testing.T) { diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index cca8c5a7..277e7438 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -29,6 +29,7 @@ type ContactService struct { contactInboxSvc *ContactInboxService noteRepo *repository.NoteRepo searchIndexer SearchIndexer + searchReader ContactSearchReader } // NewContactService creates a new Contact service. @@ -40,6 +41,10 @@ func (s *ContactService) SetSearchIndexer(indexer SearchIndexer) { s.searchIndexer = indexer } +func (s *ContactService) SetSearchReader(reader ContactSearchReader) { + s.searchReader = reader +} + func (s *ContactService) indexContact(ctx context.Context, contact *model.Contact) { if s.searchIndexer != nil { logSearchIndexError("contact", contact.ID, s.searchIndexer.IndexContact(ctx, contact)) @@ -74,9 +79,44 @@ func (s *ContactService) Search(ctx context.Context, accountID uint, query strin if query == "" { return s.repo.FindByAccount(ctx, accountID, offset, limit, sort, labels...) } + if s.searchReader != nil { + filter := serviceSearchFilter(offset, limit, sort, searchMode, search.ResultTypeContact) + filter.Labels = firstServiceContactLabelFilter(labels) + results, total, err := s.searchReader.SearchContacts(ctx, accountID, query, filter) + if err != nil { + return nil, 0, err + } + contacts, err := s.contactsFromSearchResults(ctx, accountID, results) + if err != nil { + return nil, 0, err + } + return contacts, total, nil + } return s.repo.Search(ctx, accountID, query, offset, limit, sort, searchMode, labels...) } +func (s *ContactService) contactsFromSearchResults(ctx context.Context, accountID uint, results []search.SearchResult) ([]model.Contact, error) { + contacts := make([]model.Contact, 0, len(results)) + for _, result := range results { + if result.ID == 0 || result.AccountID != accountID { + continue + } + contact, err := s.repo.FindByAccountAndID(ctx, accountID, result.ID) + if err != nil { + continue + } + contacts = append(contacts, *contact) + } + return contacts, nil +} + +func firstServiceContactLabelFilter(filters [][]string) []string { + if len(filters) == 0 { + return nil + } + return normalizeContactServiceLabels(filters[0]) +} + // GetByID retrieves a single contact. func (s *ContactService) GetByID(ctx context.Context, id uint) (*model.Contact, error) { return s.repo.FindByID(ctx, id) diff --git a/internal/service/contact_service_g3_test.go b/internal/service/contact_service_g3_test.go index 42d888a7..16e51929 100644 --- a/internal/service/contact_service_g3_test.go +++ b/internal/service/contact_service_g3_test.go @@ -13,8 +13,22 @@ import ( "gorm.io/datatypes" "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/search" ) +type mockContactSearchReader struct { + results []search.SearchResult + total int64 + filter *search.SearchFilter + query string +} + +func (m *mockContactSearchReader) SearchContacts(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]search.SearchResult, int64, error) { + m.query = query + m.filter = filter + return m.results, m.total, nil +} + // ========== ListActive ========== func TestContactService_ListActive_ReturnsContactsWithActivity(t *testing.T) { @@ -73,6 +87,32 @@ func TestContactService_ListActive_EmptyWhenNoContacts(t *testing.T) { assert.Len(t, contacts, 0) } +func TestContactService_Search_UsesSearchReader(t *testing.T) { + db, _, svc := setupContactService(t) + account := createTestAccount(t, db) + contact := &model.Contact{AccountID: account.ID, Name: "Meili Contact", Email: "meili@example.com"} + rejected := &model.Contact{AccountID: account.ID, Name: "DB Contact", Email: "db@example.com"} + require.NoError(t, db.Create(contact).Error) + require.NoError(t, db.Create(rejected).Error) + + reader := &mockContactSearchReader{ + results: []search.SearchResult{{Type: search.ResultTypeContact, ID: contact.ID, AccountID: account.ID}}, + total: 1, + } + svc.SetSearchReader(reader) + + contacts, total, err := svc.Search(context.Background(), account.ID, "meili", 0, 10, "", search.SearchModeILike) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + require.Len(t, contacts, 1) + assert.Equal(t, contact.ID, contacts[0].ID) + assert.Equal(t, "meili", reader.query) + require.NotNil(t, reader.filter) + assert.Equal(t, []search.SearchResultType{search.ResultTypeContact}, reader.filter.Types) + assert.Equal(t, 1, reader.filter.Page) + assert.Equal(t, 10, reader.filter.PerPage) +} + func TestContactService_ListActive_Pagination(t *testing.T) { db, _, svc := setupContactService(t) account := createTestAccount(t, db) diff --git a/internal/service/search_indexer.go b/internal/service/search_indexer.go index 8dda0a25..ace021c0 100644 --- a/internal/service/search_indexer.go +++ b/internal/service/search_indexer.go @@ -4,6 +4,7 @@ import ( "context" "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/search" applogger "github.com/gochat/gochat/pkg/logger" ) @@ -22,6 +23,36 @@ type SearchIndexer interface { DeleteArticle(ctx context.Context, accountID uint, id uint) error } +type ContactSearchReader interface { + SearchContacts(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]search.SearchResult, int64, error) +} + +type CompanySearchReader interface { + SearchCompanies(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]search.SearchResult, int64, error) +} + +func serviceSearchFilter(offset, limit int, sort string, searchMode search.SearchMode, resultType search.SearchResultType) *search.SearchFilter { + if limit <= 0 { + limit = search.DefaultPerPage + } + page := 1 + if offset > 0 { + page = offset/limit + 1 + } + sortBy := sort + if sortBy == "" { + sortBy = search.DefaultSortBy + } + return &search.SearchFilter{ + SearchMode: searchMode, + Types: []search.SearchResultType{resultType}, + SortBy: sortBy, + SortOrder: search.DefaultSortOrder, + Page: page, + PerPage: limit, + } +} + func logSearchIndexError(entity string, id uint, err error) { if err != nil { applogger.L().Warnf("search index sync failed for %s %d: %v", entity, id, err)