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
+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)