feat(crm): route crm search through meilisearch
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user