fix(search): align search filters

This commit is contained in:
2026-06-07 10:01:02 +08:00
parent 3c437dd94c
commit 023ab10b31
7 changed files with 238 additions and 25 deletions
+8 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 P3.89 SearchAPI fixed page-size parity, prepared as `fix(search): align search page size`.
- Latest implementation checkpoint: this checkpoint, prepared as `fix(search): align search page size`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchAPI fixed `.per(15)` pagination parity. No route artifacts change.
- Current tracking checkpoint: 2026-06-07 P3.90 SearchAPI filter-surface parity, prepared as `fix(search): align search filters`.
- Latest implementation checkpoint: this checkpoint, prepared as `fix(search): align search filters`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchAPI supported-filter parity. No route artifacts change.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: SearchAPI handler filters now ignore caller `per_page` and force Chatwoot's fixed `.per(15)` page size before DB fallback or Meilisearch execution. This retains P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, P3.83 conversation ordering, P3.82 contact-conversation reindex fanout, P3.81 conversation identity search, P3.80 search time-bound parity, P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: SearchAPI filters now match the reference surface: date filters apply across SearchAPI result families, `from`/`inbox_id` apply only to message search, and unsupported local filters such as status, priority, labels, message type, content type, private, contact source, portal, article status, and locale are stripped before DB fallback or Meilisearch execution. This retains P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, P3.83 conversation ordering, P3.82 contact-conversation reindex fanout, P3.81 conversation identity search, P3.80 search time-bound parity, P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.90 SearchAPI filter-surface parity | Implemented for reused SearchAPI endpoints: only Chatwoot-supported search filters are allowed through. `since/until` date filters remain available across result families when advanced search is enabled; `from` and `inbox_id` are preserved only for message search; unsupported local filters are stripped before DB fallback and Meilisearch execution. Global search applies message-only filters only to message results, so conversations/contacts/articles are not narrowed by message inbox or sender filters. | Keep in Review; reopen from B12 advanced-search smoke or fresh reference evidence for new upstream filter params, Searchkick advanced-search extensions, or endpoint-specific filter drift. | Focused SearchHandler, SearchService, and Meilisearch filter-surface tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.89 SearchAPI fixed page-size parity | Implemented for reused SearchAPI endpoints: GoChat now ignores local `per_page` query params and forces Chatwoot's fixed `.per(15)` page size for global, conversation, message, contact, and article search before DB fallback or Meilisearch execution. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for endpoint-specific pagination changes, Kaminari default drift, or frontend callers adding explicit per-page support upstream. | Focused SearchHandler pagination tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.88 global search type-scope parity | Implemented for reused global SearchAPI: `GET /search` now scopes backend searches to the same four result families rendered by Chatwoot `search/index.json.jbuilder` (`conversations`, `contacts`, `messages`, `articles`) and ignores unsupported local `types=company/help_center` expansion before DB fallback or Meilisearch execution. | Keep in Review; reopen from B12 global-search smoke or fresh reference/frontend evidence for additional rendered result families, company search UI integration, or Searchkick all-type behavior drift. | Focused SearchHandler global type-scope tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.87 search article payload parity | Implemented for reused SearchAPI article cards: DB-backed handlers and Meilisearch documents now carry Chatwoot `_article` `portal_slug` and `category_name` fields, so article search cards can route to `portals/:portalSlug/:locale/articles/edit/:id` and display category names without adapter code. Durable index replay and full reindex preload article portal/category relations. | Keep in Review; reopen from B12 search/help-center smoke or fresh reference evidence for article description payload drift, translated-article locale routing, or category/portal deletion edge cases. | Focused SearchHandler, SearchRepo, DurableSearchIndexer, reindex, and document-builder tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
@@ -253,6 +254,7 @@ These rows are the executable development plan from this point forward. A checkp
| ID | Owner files | Reference files | Work to land | Exit gate |
| --- | --- | --- | --- | --- |
| P3.90 SearchAPI filter-surface parity | `internal/handler/api/v1/search_handler.go`, `internal/search/search_service.go`, `internal/search/engine_meili.go`, SearchHandler/SearchService/Meilisearch tests | `reference/chatwoot/app/services/search_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/modules/search/components/SearchView.vue`, `reference/chatwoot/app/javascript/dashboard/modules/search/helpers/searchHelper.js` | SearchAPI filters now match the reused dashboard/reference surface: `since/until` are retained as advanced date filters; `from` and `inbox_id` are retained for message search only; local filters not present in Chatwoot `SearchService` (`status`, `priority`, `labels`, `assignee_id`, `team_id`, `message_type`, `content_type`, `private`, `contact_source`, `portal_id`, `article_status`, `locale`) are stripped by the handler. Global search applies message-only filters only to message results, including Meilisearch multi-type searches. | Review by `fix(search): align search filters`; focused tests prove conversation endpoints strip unsupported filters, message endpoints keep only `from/inbox_id`, global service filters per result type, and Meilisearch applies explicit inbox only to messages; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| P3.89 SearchAPI fixed page-size parity | `internal/handler/api/v1/search_handler.go`, `internal/handler/api/v1/search_handler_test.go` | `reference/chatwoot/app/services/search_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/javascript/dashboard/api/search.js` | SearchAPI handlers now match Chatwoot `SearchService` pagination by forcing `PerPage = 15` for global, conversation, message, contact, and article search regardless of local `per_page` query params. This keeps DB fallback and Meilisearch searches on the same fixed page-size contract used by every `page(params[:page]).per(15)` branch in the reference. | Review by `fix(search): align search page size`; focused SearchHandler tests prove explicit `per_page` on global/message search is ignored and the backend filter remains `15`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| P3.88 global search type-scope parity | `internal/handler/api/v1/search_handler.go`, `internal/handler/api/v1/search_handler_test.go` | `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/services/search_service.rb#perform`, `reference/chatwoot/app/views/api/v1/accounts/search/index.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/search.js` | Global SearchAPI now follows Chatwoot's `search('all')` rendered surface by forcing backend execution to `conversations`, `contacts`, `messages`, and `articles` for `GET /search`, ignoring unsupported local `types=company/help_center` expansions before DB fallback or Meilisearch execution. | Review by `fix(search): scope global result types`; focused SearchHandler tests prove unsupported `types=company` does not call company search and the raw payload remains the four Chatwoot result families; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| P3.87 search article payload parity | `internal/handler/api/v1/search_handler.go`, `internal/search/engine.go`, `internal/service/search_indexer_worker.go`, `cmd/reindex_search/main.go`, search handler/document/indexer tests | `reference/chatwoot/app/views/api/v1/accounts/search/_article.json.jbuilder`, `articles.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/modules/search/components/SearchResultArticlesList.vue`, `SearchResultArticleItem.vue` | Article SearchAPI results now match the reused frontend card route/display fields: `portal_slug` is hydrated from the article portal, `category_name` is hydrated from the optional article category, DB-backed handlers reload those relations for result rows and Meilisearch hits, and durable/full reindex paths preload the relations before document creation. | Review by `feat(search): align article result payloads`; focused SearchHandler, SearchRepo, DurableSearchIndexer, reindex, and document-builder tests prove DB-backed payload hydration plus Meilisearch nested payload shape; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
@@ -366,6 +368,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `fix(search): align search filters` | Advances P3.90 with Chatwoot SearchAPI filter-surface parity. Search handlers now strip unsupported local filter params, retain only date filters plus message-only `from/inbox_id`, and global search applies those message-only filters only to message results in both DB fallback and Meilisearch paths. | `go test ./internal/handler/api/v1 ./internal/search -run 'TestSearchHandler_Search(Conversations_IgnoresUnsupportedSearchFilters\|Messages_KeepsOnlyReferenceAdvancedFilters)\|TestGlobalSearch_AppliesReferenceFiltersPerResultType\|TestMeiliSearchEngine_SearchAppliesExplicitInboxOnlyToMessages' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.90 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `fix(search): align search page size` | Advances P3.89 with Chatwoot SearchAPI pagination parity. Global/entity search handlers now force the backend filter to the reference fixed page size of `15` and ignore local `per_page` query params before DB fallback or Meilisearch execution, matching every `page(params[:page]).per(15)` branch in `SearchService`. | `go test ./internal/handler/api/v1 -run 'TestSearchHandler_(GlobalSearch_WithFilterParams\|SearchMessages_Success)' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.89 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `fix(search): scope global result types` | Advances P3.88 with Chatwoot global SearchAPI result-family parity. `GET /search` now forces the backend filter to conversations, contacts, messages, and articles before DB fallback or Meilisearch execution, matching `SearchService#perform` plus `search/index.json.jbuilder` and preventing local company/help-center document types from affecting reused frontend global-search results. | `go test ./internal/handler/api/v1 -run 'TestSearchHandler_GlobalSearch_(Success\|WithFilterParams\|UsesReferenceResultTypes)' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.88 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(search): align article result payloads` | Advances P3.87 with Chatwoot SearchAPI article result payload parity. Article search hits now include `portal_slug` and `category_name` from the same partial fields consumed by the reused article search card; DB-backed handlers reload portal/category relations for article results and Meilisearch hits, and durable/full reindex paths preload those relations before document creation. | `go test ./internal/handler/api/v1 ./internal/search ./internal/repository ./internal/service ./cmd/reindex_search -run 'TestSearchHandler_SearchArticles\|TestArticleDocumentCarriesSearchPartialData\|TestSearchRepo_SearchArticles\|TestDurableSearchIndexerPreloadsArticlePortalAndCategory' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.87 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
@@ -2736,3 +2739,4 @@ Verification milestone gates:
- 2026-06-07: P3.87 search article payload checkpoint prepared as `feat(search): align article result payloads`; audited Chatwoot search `_article.json.jbuilder`, `articles.json.jbuilder`, and reused `SearchResultArticlesList.vue`/`SearchResultArticleItem.vue`. GoChat SearchAPI article results now hydrate `portal_slug` and `category_name` from portal/category relations for DB-backed handlers and Meilisearch hits; article documents, durable index replay, and full `cmd/reindex_search` now preload or carry those route/display fields where available. Focused SearchHandler, SearchRepo, DurableSearchIndexer, reindex, and document-builder tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.88 global search type-scope checkpoint prepared as `fix(search): scope global result types`; audited Chatwoot `SearchController#search`, `SearchService#perform`, `search/index.json.jbuilder`, and reused dashboard `SearchAPI.get`. GoChat global SearchAPI now forces backend execution to the four rendered Chatwoot result families (`conversations`, `contacts`, `messages`, `articles`) and ignores unsupported local `types=company/help_center` expansion before DB fallback or Meilisearch search, so hidden local document types cannot affect reused frontend global-search results. Focused SearchHandler tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.89 SearchAPI fixed page-size checkpoint prepared as `fix(search): align search page size`; audited Chatwoot `SearchService` pagination branches, `SearchController`, and reused dashboard `SearchAPI` callers. GoChat SearchAPI handlers now ignore local `per_page` query params and force the backend filter to Chatwoot's fixed `.per(15)` page size for global, conversation, message, contact, and article searches before DB fallback or Meilisearch execution. Focused SearchHandler pagination tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.90 SearchAPI filter-surface checkpoint prepared as `fix(search): align search filters`; audited Chatwoot `SearchService`, `SearchController`, reused dashboard `SearchAPI`, `SearchView.vue`, and search helper URL param generation. GoChat SearchAPI handlers now strip unsupported local filter params, retain `since/until` date filters, keep `from/inbox_id` only for message search, and make global search apply message-only filters only to message results across DB fallback and Meilisearch execution. Focused SearchHandler/SearchService/Meilisearch tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
+28 -6
View File
@@ -85,7 +85,7 @@ func (h *SearchHandler) GlobalSearch(c *gin.Context) {
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
filter := parseChatwootSearchFilter(c, "global")
filter.Types = append([]search.SearchResultType(nil), chatwootGlobalSearchTypes...)
result, svcErr := h.svc.GlobalSearch(c.Request.Context(), accountID, query, &filter)
@@ -135,7 +135,7 @@ func (h *SearchHandler) SearchConversations(c *gin.Context) {
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
filter := parseChatwootSearchFilter(c, "conversation")
// Force type to conversations only
filter.Types = []search.SearchResultType{search.ResultTypeConversation}
@@ -186,7 +186,7 @@ func (h *SearchHandler) SearchMessages(c *gin.Context) {
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
filter := parseChatwootSearchFilter(c, "message")
// Force type to messages only
filter.Types = []search.SearchResultType{search.ResultTypeMessage}
@@ -231,7 +231,7 @@ func (h *SearchHandler) SearchContacts(c *gin.Context) {
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
filter := parseChatwootSearchFilter(c, "contact")
// Force type to contacts only
filter.Types = []search.SearchResultType{search.ResultTypeContact}
@@ -279,7 +279,7 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) {
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
filter := parseChatwootSearchFilter(c, "article")
// Force type to articles only
filter.Types = []search.SearchResultType{search.ResultTypeArticle}
@@ -302,12 +302,34 @@ func serializeSearchPayload(ctx context.Context, db *gorm.DB, results []search.S
}
}
func parseChatwootSearchFilter(c *gin.Context) search.SearchFilter {
func parseChatwootSearchFilter(c *gin.Context, scope string) search.SearchFilter {
filter := search.ParseSearchFilter(c)
if userID := getUserID(c); userID != 0 {
filter.CurrentUserID = &userID
}
filter.PerPage = chatwootSearchPerPage
filter = sanitizeChatwootSearchFilter(filter, scope)
return filter
}
func sanitizeChatwootSearchFilter(filter search.SearchFilter, scope string) search.SearchFilter {
filter.Status = nil
filter.Priority = nil
filter.AssigneeID = nil
filter.TeamID = nil
filter.Labels = nil
filter.ContactSource = ""
filter.MessageType = ""
filter.ContentType = ""
filter.Private = nil
filter.PortalID = nil
filter.ArticleStatus = ""
filter.ArticleLocale = ""
if scope != "message" && scope != "global" {
filter.SenderType = ""
filter.SenderID = nil
filter.InboxID = nil
}
return filter
}
+69 -6
View File
@@ -25,27 +25,31 @@ type mockSearchRepo struct {
conversations []model.Conversation
convTotal int64
convErr error
convFilter *search.SearchFilter
messages []model.Message
msgTotal int64
msgErr error
msgFilter *search.SearchFilter
contacts []model.Contact
contactTotal int64
contactErr error
contacts []model.Contact
contactTotal int64
contactErr error
contactFilter *search.SearchFilter
companies []model.Company
companyTotal int64
companyErr error
companyCalled bool
articles []model.Article
articleTotal int64
articleErr error
articles []model.Article
articleTotal int64
articleErr error
articleFilter *search.SearchFilter
}
func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Conversation, int64, error) {
m.convFilter = filter
return m.conversations, m.convTotal, m.convErr
}
@@ -55,6 +59,7 @@ func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, que
}
func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Contact, int64, error) {
m.contactFilter = filter
return m.contacts, m.contactTotal, m.contactErr
}
@@ -64,6 +69,7 @@ func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, qu
}
func (m *mockSearchRepo) SearchArticles(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Article, int64, error) {
m.articleFilter = filter
return m.articles, m.articleTotal, m.articleErr
}
@@ -287,6 +293,34 @@ func TestSearchHandler_SearchConversations_ChatwootPayloadShape(t *testing.T) {
assert.Equal(t, float64(0), item["message"].(map[string]any)["message_type"])
}
func TestSearchHandler_SearchConversations_IgnoresUnsupportedSearchFilters(t *testing.T) {
repo := &mockSearchRepo{
conversations: []model.Conversation{makeConversation(1, "open")},
convTotal: 1,
}
svc := search.NewSearchService(repo)
handler := NewSearchHandler(svc)
router := setupSearchHandlerRouter(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/conversations?q=billing&status=resolved&priority=urgent&labels=vip&inbox_id=5&from=agent:7&message_type=outgoing&contact_source=email&portal_id=2&article_status=draft&locale=fr", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, repo.convFilter)
assert.Empty(t, repo.convFilter.Status)
assert.Empty(t, repo.convFilter.Priority)
assert.Empty(t, repo.convFilter.Labels)
assert.Nil(t, repo.convFilter.InboxID)
assert.Empty(t, repo.convFilter.SenderType)
assert.Nil(t, repo.convFilter.SenderID)
assert.Empty(t, repo.convFilter.MessageType)
assert.Empty(t, repo.convFilter.ContactSource)
assert.Nil(t, repo.convFilter.PortalID)
assert.Empty(t, repo.convFilter.ArticleStatus)
assert.Empty(t, repo.convFilter.ArticleLocale)
}
func TestSearchHandler_SearchConversations_InvalidAccountID(t *testing.T) {
repo := &mockSearchRepo{}
svc := search.NewSearchService(repo)
@@ -363,6 +397,35 @@ func TestSearchHandler_SearchMessages_AddsCurrentUserToFilter(t *testing.T) {
}
}
func TestSearchHandler_SearchMessages_KeepsOnlyReferenceAdvancedFilters(t *testing.T) {
repo := &mockSearchRepo{
messages: []model.Message{makeMessage(1, 1, "hello world")},
msgTotal: 1,
}
svc := search.NewSearchService(repo)
handler := NewSearchHandler(svc)
router := setupSearchHandlerRouter(handler)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello&from=agent:7&inbox_id=5&message_type=outgoing&content_type=text&private=true&status=resolved&contact_source=email&portal_id=2&article_status=draft", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, repo.msgFilter)
require.NotNil(t, repo.msgFilter.SenderID)
assert.Equal(t, "agent", repo.msgFilter.SenderType)
assert.Equal(t, uint(7), *repo.msgFilter.SenderID)
require.NotNil(t, repo.msgFilter.InboxID)
assert.Equal(t, uint(5), *repo.msgFilter.InboxID)
assert.Empty(t, repo.msgFilter.MessageType)
assert.Empty(t, repo.msgFilter.ContentType)
assert.Nil(t, repo.msgFilter.Private)
assert.Empty(t, repo.msgFilter.Status)
assert.Empty(t, repo.msgFilter.ContactSource)
assert.Nil(t, repo.msgFilter.PortalID)
assert.Empty(t, repo.msgFilter.ArticleStatus)
}
func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) {
svc := search.NewSearchServiceWithEngine(&stubSearchEngine{
resp: &search.SearchResponse{
+3 -3
View File
@@ -203,10 +203,10 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul
if docType == ResultTypeConversation || docType == ResultTypeMessage {
parts = appendInboxAccessFilter(parts, filter)
}
if filter.AdvancedSearchEnabled && filter.InboxID != nil && searchFilterInboxAllowed(filter, *filter.InboxID) {
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
}
if docType == ResultTypeMessage {
if filter.AdvancedSearchEnabled && filter.InboxID != nil && searchFilterInboxAllowed(filter, *filter.InboxID) {
parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID))
}
if filter.MessageCreatedAfter != nil {
parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.MessageCreatedAfter.Unix()))
}
+22
View File
@@ -333,6 +333,28 @@ func TestMeiliSearchEngine_SearchIgnoresInaccessibleInboxParam(t *testing.T) {
assert.Equal(t, "account_id = 42 AND (inbox_id = 3 OR inbox_id = 5)", requestBody["filter"])
}
func TestMeiliSearchEngine_SearchAppliesExplicitInboxOnlyToMessages(t *testing.T) {
seen := map[string]string{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
var requestBody map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
seen[r.URL.Path] = requestBody["filter"].(string)
return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil
})
engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"})
engine.client.SetTransport(transport)
inboxID := uint(9)
filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeConversation, ResultTypeContact, ResultTypeArticle, ResultTypeMessage}, AdvancedSearchEnabled: true, InboxID: &inboxID}
_, err := engine.Search(context.Background(), 42, "hello", filter)
require.NoError(t, err)
assert.Equal(t, "account_id = 42", seen["/indexes/gochat_conversations/search"])
assert.Equal(t, "account_id = 42", seen["/indexes/gochat_contacts/search"])
assert.Equal(t, "account_id = 42", seen["/indexes/gochat_articles/search"])
assert.Equal(t, "account_id = 42 AND inbox_id = 9", seen["/indexes/gochat_messages/search"])
}
func TestMeiliSearchEngine_IndexAndDeleteDocument(t *testing.T) {
seen := []string{}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
+49 -6
View File
@@ -89,7 +89,7 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
if s.engine != nil {
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, s.withAdvancedSearchFeature(ctx, accountID, filter))))
return s.engine.Search(ctx, accountID, query, filter)
return s.globalSearchWithEngine(ctx, accountID, query, filter)
}
filter = s.withMessageRecentBase(s.withSearchAccess(ctx, accountID, s.withContactResolvedScope(ctx, accountID, s.withAdvancedSearchFeature(ctx, accountID, filter))))
@@ -99,7 +99,7 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
// Search conversations
if filter.ShouldSearchType(ResultTypeConversation) {
conversations, convCount, err := s.searchRepo.SearchConversations(ctx, accountID, query, filter)
conversations, convCount, err := s.searchRepo.SearchConversations(ctx, accountID, query, referenceGlobalFilterForType(filter, ResultTypeConversation))
if err != nil {
applogger.L().Warnf("search conversations error: %v", err)
} else {
@@ -120,7 +120,7 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
// Search messages
if filter.ShouldSearchType(ResultTypeMessage) {
messages, msgCount, err := s.searchRepo.SearchMessages(ctx, accountID, query, filter)
messages, msgCount, err := s.searchRepo.SearchMessages(ctx, accountID, query, referenceGlobalFilterForType(filter, ResultTypeMessage))
if err != nil {
applogger.L().Warnf("search messages error: %v", err)
} else {
@@ -141,7 +141,7 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
// Search contacts
if filter.ShouldSearchType(ResultTypeContact) {
contacts, contactCount, err := s.searchRepo.SearchContacts(ctx, accountID, query, filter)
contacts, contactCount, err := s.searchRepo.SearchContacts(ctx, accountID, query, referenceGlobalFilterForType(filter, ResultTypeContact))
if err != nil {
applogger.L().Warnf("search contacts error: %v", err)
} else {
@@ -162,7 +162,7 @@ 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)
companies, companyCount, err := s.searchRepo.SearchCompanies(ctx, accountID, query, referenceGlobalFilterForType(filter, ResultTypeCompany))
if err != nil {
applogger.L().Warnf("search companies error: %v", err)
} else {
@@ -183,7 +183,7 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query
// Search articles (Knowledge Base)
if filter.ShouldSearchType(ResultTypeArticle) {
articles, articleCount, err := s.searchRepo.SearchArticles(ctx, accountID, query, filter)
articles, articleCount, err := s.searchRepo.SearchArticles(ctx, accountID, query, referenceGlobalFilterForType(filter, ResultTypeArticle))
if err != nil {
applogger.L().Warnf("search articles error: %v", err)
} else {
@@ -360,6 +360,49 @@ func (s *SearchService) searchWithEngineForType(ctx context.Context, accountID u
return resp.Results, resp.ByType[string(resultType)], nil
}
func (s *SearchService) globalSearchWithEngine(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) {
var allResults []SearchResult
byType := map[string]int64{}
var totalCount int64
for _, resultType := range []SearchResultType{ResultTypeConversation, ResultTypeMessage, ResultTypeContact, ResultTypeCompany, ResultTypeArticle} {
if !filter.ShouldSearchType(resultType) {
continue
}
results, total, err := s.searchWithEngineForType(ctx, accountID, query, referenceGlobalFilterForType(filter, resultType), resultType)
if err != nil {
return nil, err
}
allResults = append(allResults, results...)
byType[string(resultType)] = total
totalCount += total
}
sortResultsByScore(allResults)
return &SearchResponse{Results: allResults, TotalCount: totalCount, ByType: byType, Page: filter.Page, PerPage: filter.PerPage, Query: query}, nil
}
func referenceGlobalFilterForType(filter *SearchFilter, resultType SearchResultType) *SearchFilter {
clone := cloneSearchFilter(filter)
clone.Types = []SearchResultType{resultType}
clone.Status = nil
clone.Priority = nil
clone.AssigneeID = nil
clone.TeamID = nil
clone.Labels = nil
clone.ContactSource = ""
clone.MessageType = ""
clone.ContentType = ""
clone.Private = nil
clone.PortalID = nil
clone.ArticleStatus = ""
clone.ArticleLocale = ""
if resultType != ResultTypeMessage {
clone.SenderType = ""
clone.SenderID = nil
clone.InboxID = nil
}
return clone
}
func cloneSearchFilter(filter *SearchFilter) *SearchFilter {
if filter == nil {
return &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder}
+59
View File
@@ -284,6 +284,65 @@ func TestGlobalSearch_WithStatusFilter(t *testing.T) {
assert.Len(t, resp.Results, 1)
}
func TestGlobalSearch_AppliesReferenceFiltersPerResultType(t *testing.T) {
repo := &mockSearchRepo{features: map[string]bool{"advanced_search": true}}
repo.conversations = []model.Conversation{makeConversation(1, 1, "open", "")}
repo.convCount = 1
repo.messages = []model.Message{makeMessage(10, 1, "test", "incoming")}
repo.msgCount = 1
repo.contacts = []model.Contact{makeContact(20, 1, "John Doe", "john@example.com", "")}
repo.contactCount = 1
repo.articles = []model.Article{makeArticle(30, 1, "Help", "desc", "content", "published")}
repo.articleCount = 1
senderID := uint(7)
inboxID := uint(5)
portalID := uint(2)
private := true
filter := &SearchFilter{
Page: 1,
PerPage: 15,
Types: []SearchResultType{ResultTypeConversation, ResultTypeMessage, ResultTypeContact, ResultTypeArticle},
Status: []string{"resolved"},
Priority: []string{"urgent"},
Labels: []string{"vip"},
InboxID: &inboxID,
SenderType: "agent",
SenderID: &senderID,
MessageType: "outgoing",
ContentType: "text",
Private: &private,
ContactSource: "email",
PortalID: &portalID,
ArticleStatus: "draft",
ArticleLocale: "fr",
}
svc := NewSearchService(repo)
_, err := svc.GlobalSearch(context.Background(), 1, "test", filter)
require.NoError(t, err)
require.NotNil(t, repo.convFilter)
assert.Empty(t, repo.convFilter.Status)
assert.Empty(t, repo.convFilter.Priority)
assert.Empty(t, repo.convFilter.Labels)
assert.Nil(t, repo.convFilter.InboxID)
require.NotNil(t, repo.msgFilter)
assert.Equal(t, "agent", repo.msgFilter.SenderType)
require.NotNil(t, repo.msgFilter.SenderID)
assert.Equal(t, senderID, *repo.msgFilter.SenderID)
require.NotNil(t, repo.msgFilter.InboxID)
assert.Equal(t, inboxID, *repo.msgFilter.InboxID)
assert.Empty(t, repo.msgFilter.MessageType)
assert.Empty(t, repo.msgFilter.ContentType)
assert.Nil(t, repo.msgFilter.Private)
require.NotNil(t, repo.contactFilter)
assert.Empty(t, repo.contactFilter.ContactSource)
require.NotNil(t, repo.articleFilter)
assert.Nil(t, repo.articleFilter.PortalID)
assert.Empty(t, repo.articleFilter.ArticleStatus)
assert.Empty(t, repo.articleFilter.ArticleLocale)
}
// --- Convenience method tests ---
func TestSearchConversations_ConvenienceMethod(t *testing.T) {