feat(crm): route crm search through meilisearch

This commit is contained in:
2026-06-05 04:16:49 +08:00
parent 7a7bc4197a
commit 202da194e1
11 changed files with 318 additions and 18 deletions
+2
View File
@@ -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
+15 -7
View File
@@ -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)
}
}
+33 -1
View File
@@ -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
}
}
+2 -1
View File
@@ -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)
}
}
+45
View File
@@ -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) {
+41 -9
View File
@@ -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)
}
}
+32
View File
@@ -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)
+37
View File
@@ -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) {
+40
View File
@@ -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)
@@ -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)
+31
View File
@@ -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)