From 7b232ce850cd38bdae21a3b81373dbe6fe1dd5ca Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 05:41:34 +0800 Subject: [PATCH] feat(search): align contact resolved scope --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 18 ++-- internal/handler/api/v1/search_handler.go | 2 +- internal/repository/search_repo.go | 101 +++++++++++++--------- internal/repository/search_repo_test.go | 40 ++++++++- internal/search/engine.go | 77 +++++++++-------- internal/search/engine_db.go | 2 +- internal/search/engine_meili.go | 15 +++- internal/search/engine_test.go | 52 +++++++++++ internal/search/search_filter.go | 3 + internal/search/search_service.go | 37 +++++++- internal/search/search_service_test.go | 44 +++++++++- 11 files changed, 294 insertions(+), 97 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index eab3618b..1f09fc6c 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.69 contact resolved-scope parity, prepared as `feat(contacts): align resolved scope`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): align resolved scope`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Contact.resolved_contacts` behavior for contact index, filter, and export scopes. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.70 global contact search resolved-scope parity, prepared as `feat(search): align contact resolved scope`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align contact resolved scope`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `SearchService#filter_contacts` resolved-contact behavior for global search and search namespaced contact results. 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: contact index, filter, and export scopes now follow Chatwoot `Contact.resolved_contacts`: default CRM surfaces include only contacts with email, phone number, or identifier, while `crm_v2` accounts include only `contact_type = lead`. Existing P3.68 contact filter payload parity, P3.64-P3.67 conversation filter payload parity, P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. 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 remaining contact search/global-search resolved-contact drift, message filters, conversation permission-filter depth, contact filter frontend/reference drift such as frontend-only `referer`, or 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. +- Worktree status at this implementation checkpoint: global search contact results now follow Chatwoot `SearchService#filter_contacts` by applying `Contact.resolved_contacts`: Meilisearch indexes `contact_type` and `contact_has_details`, default search scopes require email, phone number, or identifier, and `crm_v2` scopes require `contact_type = lead`. Dashboard `contacts#search` intentionally remains unscoped because the reference controller searches `Current.account.contacts` directly. Existing P3.69 contact index/filter/export resolved-scope parity, P3.68 contact filter payload parity, P3.64-P3.67 conversation filter payload parity, P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. 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 message filters, conversation permission-filter depth, contact filter frontend/reference drift such as frontend-only `referer`, CRM v2 feature-flag serialization, import paths creating unexpected contact types, or 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. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -156,7 +156,8 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 0 | P3.69 contact resolved-scope parity | Implemented for reused CRM contact index/filter/export flows: GoChat now applies Chatwoot `Contact.resolved_contacts` before listing, advanced filtering, and export selection. Default CRM scope keeps contacts with email, phone number, or identifier; `crm_v2` scope keeps only `contact_type = lead`. | Keep in Review; reopen from B12 CRM smoke or fresh reference evidence for contact search/global search resolved-scope drift, CRM v2 feature-flag serialization, or import paths creating unexpected contact types. | Focused contact repository and handler tests, contact regression tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.70 global contact search resolved-scope parity | Implemented for Chatwoot `SearchService#filter_contacts`: GoChat global search and `/search/contacts` now apply resolved-contact scoping to contact results. Meilisearch contact documents carry `contact_type` and `contact_has_details`, default scopes require email/phone/identifier, and `crm_v2` scopes require `contact_type = lead`. | Keep in Review; dashboard `contacts#search` intentionally remains unscoped to match `ContactsController#search`; reopen from B12 search smoke or fresh reference evidence for Meilisearch live index drift, CRM v2 feature-flag serialization, or missing contact reindex paths after contact-type/detail changes. | Focused search service, Meilisearch engine, SearchRepo, and search handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.69 contact resolved-scope parity | Implemented for reused CRM contact index/filter/export flows: GoChat now applies Chatwoot `Contact.resolved_contacts` before listing, advanced filtering, and export selection. Default CRM scope keeps contacts with email, phone number, or identifier; `crm_v2` scope keeps only `contact_type = lead`. | Keep in Review; reopen from B12 CRM smoke or fresh reference evidence for CRM v2 feature-flag serialization, import paths creating unexpected contact types, or export/filter/index resolved-scope drift. | Focused contact repository and handler tests, contact regression tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.68 contact filter payload parity | Implemented for reused CRM contact advanced filters: `POST /contacts/filter` now accepts Chatwoot `{ payload: [...] }` conditions for standard contact fields, additional contact attributes, labels, dates, query operators, and account-scoped contact custom attributes. | Keep in Review; reopen from B12 CRM filter smoke or fresh reference evidence for frontend-only `referer`, exact PostgreSQL JSON/date casting drift, or contact `base_relation` CRM v2 resolved-contact scope. | Focused contact filter handler tests, contact repository/service/handler regression tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.67 conversation filter standard-operator parity | Implemented for reused dashboard advanced conversation filters: `{ payload: [...] }` now covers Chatwoot standard conversation operators for labels, presence checks, display-id contains, and date comparisons/days-before on `created_at` and `last_activity_at`. | Keep in Review; reopen from B12 filter smoke or fresh reference evidence for message filters, contact custom attributes inside conversation filters, or full `Conversations::PermissionFilterService` depth. | Focused conversation filter handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.66 conversation filter attribute parity | Implemented for reused dashboard advanced conversation filters: `{ payload: [...] }` now supports Chatwoot conversation additional attributes from `filter_keys.yml` and account-scoped conversation custom attributes, including text/list/link case handling and `not_equal_to` matching rows where the custom attribute is absent. | Keep in Review; reopen from B12 filter smoke or fresh reference evidence for message filters, contact custom attributes inside conversation filters, or full `Conversations::PermissionFilterService` depth. | Focused conversation filter handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -232,6 +233,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.70 global contact search resolved-scope parity | `internal/search/search_service.go`, `internal/search/engine.go`, `internal/search/engine_meili.go`, `internal/repository/search_repo.go`, `internal/handler/api/v1/search_handler.go`, search/repository tests | `reference/chatwoot/app/services/search_service.rb#filter_contacts`, `reference/chatwoot/app/models/contact.rb#resolved_contacts`, `reference/chatwoot/spec/models/contact_spec.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/contacts_controller.rb#search` | Global search contact branches now use Chatwoot `SearchService#filter_contacts` resolved-contact base relation. DB fallback and Meilisearch filters both exclude anonymous contacts without email, phone number, or identifier unless `crm_v2` is enabled; `crm_v2` global contact search keeps only `contact_type = lead`. Meilisearch contact documents expose `contact_type` plus `contact_has_details` as filterable attributes. The dashboard `/contacts/search` path stays unscoped because the reference contacts controller searches all account contacts directly before pagination. | Review by `feat(search): align contact resolved scope`; focused tests assert SearchService resolved-scope filter injection, Meilisearch default and CRM v2 filter expressions, contact document resolved fields, and SearchRepo default/CRM v2 resolved scopes; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.69 contact resolved-scope parity | `internal/repository/contact_repo.go`, `internal/repository/contact_repo_test.go`, `internal/handler/api/v1/contact_handler_crud_test.go` | `reference/chatwoot/app/models/contact.rb#resolved_contacts`, `reference/chatwoot/spec/models/contact_spec.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/contacts_controller.rb#index/#filter`, `reference/chatwoot/app/jobs/account/contacts_export_job.rb`, `reference/chatwoot/app/services/contacts/filter_service.rb#base_relation` | Contact list, advanced filter, and export queries now share Chatwoot's resolved-contact base relation. Without `crm_v2`, anonymous contacts lacking email, phone number, and identifier are excluded from CRM index/filter/export surfaces. With `crm_v2`, only `contact_type = lead` is included, so lead-only contacts remain visible while customer/visitor records are hidden from those scopes. | Review by `feat(contacts): align resolved scope`; focused repository tests assert default resolved scope, CRM v2 lead-only scope, and export resolved scope; handler tests assert list scope and CRM v2 list behavior; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.68 contact filter payload parity | `internal/repository/contact_repo.go`, `internal/handler/api/v1/contact_handler.go`, `internal/handler/api/v1/contact_handler_crud_test.go` | `reference/chatwoot/app/services/contacts/filter_service.rb`, `reference/chatwoot/app/services/filter_service.rb`, `reference/chatwoot/app/helpers/filters/filter_helper.rb`, `reference/chatwoot/app/services/filters/custom_attribute_filter_helper.rb`, `reference/chatwoot/lib/filters/filter_keys.yml`, `reference/chatwoot/app/javascript/dashboard/components-next/filter/contactProvider.js` | Contact filter now accepts the reused CRM `{ payload: [...] }` body, validates `query_operator`, returns raw `422 { error }` for payload validation failures, normalizes phone numbers and case-insensitive values like Chatwoot, supports standard contact fields, additional attributes `country_code`, `city`, `company_name`, labels, date comparisons/days-before, and account-scoped contact custom attributes with null-inclusive `not_equal_to`. Backend reference excludes frontend-only `referer`, so it remains a tracked drift candidate rather than a silently accepted local extension. | Review by `feat(contacts): align filter payloads`; focused contact filter handler tests assert standard/additional filters, labels plus date filters, custom-attribute `not_equal_to` null inclusion, and invalid attribute errors; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.67 conversation filter standard-operator parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler_crud_test.go` | `reference/chatwoot/lib/filters/filter_keys.yml`, `reference/chatwoot/app/services/filter_service.rb#filter_operation`, `reference/chatwoot/app/services/filter_service.rb#tag_filter_query`, `reference/chatwoot/app/services/filter_service.rb#lt_gt_filter_query`, `reference/chatwoot/app/services/conversations/filter_service.rb` | Conversation filter payload now supports Chatwoot standard filter operators beyond the P3.64 basics: `assignee_id`, `inbox_id`, `team_id`, and `campaign_id` support equality plus present/absent checks; `display_id` supports contains/does-not-contain through text casting; `labels` uses account tag joins with equal/not-equal/present/absent semantics; `created_at` and `last_activity_at` support greater-than, less-than, and days-before. | Review by `feat(conversations): align standard filters`; focused filter handler tests assert presence filters, label joins, display-id contains with date filters, last-activity days-before, full `go test ./...`, and `git diff --check`. No route artifacts change. | @@ -326,7 +328,8 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | -| `feat(contacts): align resolved scope` | Advances P3.69 with Chatwoot contact resolved-scope parity. Contact index, filter, and export queries now apply `Contact.resolved_contacts`: non-CRM-v2 scopes require email, phone number, or identifier; CRM v2 scopes require `contact_type = lead`. | `go test ./internal/repository -run 'ContactRepo_FindByAccount\|ContactRepo_FindAllForExport' -count=1`; `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestList_ChatwootResolvedContacts\|ContactHandlerCRUDTestSuite/TestFilter_ChatwootPayloadCustomAttribute' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'Contact.*(Filter\|List\|Search\|Export\|Label)\|ContactHandlerCRUDTestSuite/Test(Filter\|Labels\|List\|Search\|Export)\|ContactService\|ContactRepo' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.69 to Review; continue Phase 2/3 drift audit for contact search/global-search resolved scope, frontend-only `referer`, message filters, Phase 6 placeholder audit, or B12 live smoke. | +| `feat(search): align contact resolved scope` | Advances P3.70 with Chatwoot global contact search resolved-scope parity. Global search and `/search/contacts` now apply `SearchService#filter_contacts` semantics: default contact results require email, phone number, or identifier, while `crm_v2` contact search keeps only `contact_type = lead`. Meilisearch contact documents now include filterable `contact_type` and `contact_has_details`; dashboard `contacts#search` remains unscoped to match the reference controller. | `go test ./internal/search ./internal/repository ./internal/handler/api/v1 -run 'GlobalSearch_Applies\|MeiliSearchEngine_SearchSends.*Contact\|ContactDocumentSetsResolved\|SearchRepo_SearchContacts_Resolved\|SearchContacts' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.70 to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, CRM v2 feature-flag serialization, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. | +| `feat(contacts): align resolved scope` | Advances P3.69 with Chatwoot contact resolved-scope parity. Contact index, filter, and export queries now apply `Contact.resolved_contacts`: non-CRM-v2 scopes require email, phone number, or identifier; CRM v2 scopes require `contact_type = lead`. | `go test ./internal/repository -run 'ContactRepo_FindByAccount\|ContactRepo_FindAllForExport' -count=1`; `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestList_ChatwootResolvedContacts\|ContactHandlerCRUDTestSuite/TestFilter_ChatwootPayloadCustomAttribute' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'Contact.*(Filter\|List\|Search\|Export\|Label)\|ContactHandlerCRUDTestSuite/Test(Filter\|Labels\|List\|Search\|Export)\|ContactService\|ContactRepo' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.69 to Review; continue Phase 2/3 drift audit for frontend-only `referer`, message filters, CRM v2 feature-flag serialization, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. | | `feat(contacts): align filter payloads` | Advances P3.68 with Chatwoot contact filter payload parity. `POST /contacts/filter` now understands `{ payload: [...] }` conditions for standard fields, additional attributes, labels, dates, query-operator chains, and account-scoped contact custom attributes; payload errors return raw `422 { error }`, and contact equality uses scalar `=`/`!=` semantics matching `Contacts::FilterService`. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestFilter' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'Contact.*(Filter\|List\|Search\|Export\|Label)\|ContactHandlerCRUDTestSuite/Test(Filter\|Labels\|List\|Search\|Export)\|ContactService\|ContactRepo' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.68 to Review; continue Phase 2/3 drift audit for frontend-only `referer`, message filters, conversation permission-filter depth, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align standard filters` | Advances P3.67 with Chatwoot conversation filter standard-operator parity. Payload filters now cover presence operators for assignee/inbox/team/campaign fields, display-id contains/does-not-contain through text casts, account tag joins for labels, and date/days-before comparisons for `created_at` plus Unix-second `last_activity_at`. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestFilter' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search\|Filter)\|ConversationCrudTestSuite/Test(Filter\|Search\|List)\|ConversationService' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.67 to Review; continue Phase 2/3 drift audit for message filters, contact custom attributes inside conversation filters, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align filter attributes` | Advances P3.66 with Chatwoot conversation filter additional/custom attribute parity. Payload filters now cover `browser_language`, `conversation_language`, `referer`, and `mail_subject` through JSON extraction and Chatwoot operator sets, and custom conversation attributes are resolved through account-scoped definitions with text/list/link lowercasing plus null-inclusive `not_equal_to`. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestFilter' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search\|Filter)\|ConversationCrudTestSuite/Test(Filter\|Search\|List)\|ConversationService' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.66 to Review; continue Phase 2/3 drift audit for message filters, contact custom attributes inside conversation filters, Phase 6 placeholder audit, or B12 live smoke. | @@ -2657,3 +2660,4 @@ Verification milestone gates: - 2026-06-07: P3.67 conversation filter standard-operator checkpoint prepared as `feat(conversations): align standard filters`; audited Chatwoot `filter_keys.yml`, `FilterService#tag_filter_query`, `#filter_operation`, `#lt_gt_filter_query`, and `Conversations::FilterService`. GoChat payload filters now support presence operators for assignee/inbox/team/campaign fields, label equal/not-equal/present/absent through account tag joins, `display_id` contains/does-not-contain through text casts, and date/days-before comparisons for `created_at` and Unix-second `last_activity_at`. Focused filter handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.68 contact filter payload checkpoint prepared as `feat(contacts): align filter payloads`; audited Chatwoot `Contacts::FilterService`, base `FilterService`, `Filters::FilterHelper`, `CustomAttributeFilterHelper`, contact `filter_keys.yml`, and reused `contactProvider.js`. GoChat contact filters now accept frontend `{ payload: [...] }` bodies for standard fields, additional attributes, labels, dates, query operators, and account-scoped contact custom attributes; errors return raw `422 { error }`, phone/case normalization follows the reference, and contact equality uses scalar `=`/`!=`. Focused contact filter tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.69 contact resolved-scope checkpoint prepared as `feat(contacts): align resolved scope`; audited Chatwoot `Contact.resolved_contacts`, contact model specs, contacts index/filter controller paths, contacts export job, and `Contacts::FilterService#base_relation`. GoChat contact index, advanced filter, and export scopes now exclude anonymous contacts without email/phone/identifier unless `crm_v2` is enabled, and CRM v2 accounts now show only `contact_type = lead` in those CRM surfaces. Focused repository and handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.70 global contact search resolved-scope checkpoint prepared as `feat(search): align contact resolved scope`; audited Chatwoot `SearchService#filter_contacts`, `Contact.resolved_contacts`, contact model specs, and `ContactsController#search`. GoChat global search and `/search/contacts` now apply resolved-contact scope while dashboard `/contacts/search` remains unscoped like the reference controller; Meilisearch contact docs now include `contact_type` and `contact_has_details` for default and CRM v2 filters. Focused search service, Meilisearch engine, SearchRepo, and handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/search_handler.go b/internal/handler/api/v1/search_handler.go index 2fc0fc54..00114629 100644 --- a/internal/handler/api/v1/search_handler.go +++ b/internal/handler/api/v1/search_handler.go @@ -220,7 +220,7 @@ func (h *SearchHandler) SearchContacts(c *gin.Context) { // Force type to contacts only filter.Types = []search.SearchResultType{search.ResultTypeContact} - results, total, svcErr := h.svc.SearchContacts(c.Request.Context(), accountID, query, &filter) + results, total, svcErr := h.svc.SearchResolvedContacts(c.Request.Context(), accountID, query, &filter) if svcErr != nil { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "contact search failed") return diff --git a/internal/repository/search_repo.go b/internal/repository/search_repo.go index 9cfac603..e57154dd 100644 --- a/internal/repository/search_repo.go +++ b/internal/repository/search_repo.go @@ -16,25 +16,27 @@ import ( // This is a repository-level mirror of the search.SearchFilter struct to avoid // an import cycle between internal/search and internal/repository. type RepoSearchFilter struct { - SearchMode search.SearchMode // ilike or trigram - Status []string - Priority []string - AssigneeID *uint - TeamID *uint - InboxID *uint - Labels []string - ContactSource string - MessageType string - SenderType string - SenderID *uint - ContentType string - Private *bool - DateFrom *time.Time - DateTo *time.Time - SortBy string - SortOrder string - Page int - PerPage int + SearchMode search.SearchMode // ilike or trigram + Status []string + Priority []string + AssigneeID *uint + TeamID *uint + InboxID *uint + Labels []string + ContactSource string + ContactResolvedScope bool + ContactCRMV2 bool + MessageType string + SenderType string + SenderID *uint + ContentType string + Private *bool + DateFrom *time.Time + DateTo *time.Time + SortBy string + SortOrder string + Page int + PerPage int // Article-specific filters (Knowledge Base) PortalID *uint @@ -86,28 +88,30 @@ func searchFilterToRepo(f *search.SearchFilter) *RepoSearchFilter { mode = search.SearchModeILike } return &RepoSearchFilter{ - SearchMode: mode, - Status: f.Status, - Priority: f.Priority, - AssigneeID: f.AssigneeID, - TeamID: f.TeamID, - InboxID: f.InboxID, - Labels: f.Labels, - ContactSource: f.ContactSource, - MessageType: f.MessageType, - SenderType: f.SenderType, - SenderID: f.SenderID, - ContentType: f.ContentType, - Private: f.Private, - DateFrom: f.DateFrom, - DateTo: f.DateTo, - SortBy: f.SortBy, - SortOrder: f.SortOrder, - Page: f.Page, - PerPage: f.PerPage, - PortalID: f.PortalID, - ArticleStatus: f.ArticleStatus, - ArticleLocale: f.ArticleLocale, + SearchMode: mode, + Status: f.Status, + Priority: f.Priority, + AssigneeID: f.AssigneeID, + TeamID: f.TeamID, + InboxID: f.InboxID, + Labels: f.Labels, + ContactSource: f.ContactSource, + ContactResolvedScope: f.ContactResolvedScope, + ContactCRMV2: f.ContactCRMV2, + MessageType: f.MessageType, + SenderType: f.SenderType, + SenderID: f.SenderID, + ContentType: f.ContentType, + Private: f.Private, + DateFrom: f.DateFrom, + DateTo: f.DateTo, + SortBy: f.SortBy, + SortOrder: f.SortOrder, + Page: f.Page, + PerPage: f.PerPage, + PortalID: f.PortalID, + ArticleStatus: f.ArticleStatus, + ArticleLocale: f.ArticleLocale, } } @@ -238,12 +242,27 @@ func (r *SearchRepo) SearchContacts(ctx context.Context, accountID uint, query s return r.searchContactsInternal(ctx, accountID, query, repoFilter) } +func (r *SearchRepo) AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool { + var account model.Account + if err := r.db.WithContext(ctx).Select("feature_flags").Where("id = ?", accountID).First(&account).Error; err != nil { + return false + } + return contactFeatureFlagEnabled(account.FeatureFlags, flag) +} + // searchContactsInternal is the internal implementation using *RepoSearchFilter. func (r *SearchRepo) searchContactsInternal(ctx context.Context, accountID uint, query string, filter *RepoSearchFilter) ([]model.Contact, int64, error) { var contacts []model.Contact var total int64 q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("account_id = ?", accountID) + if filter.ContactResolvedScope { + if filter.ContactCRMV2 { + q = q.Where("contacts.contact_type = ?", "lead") + } else { + q = q.Where("(contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '')") + } + } // Text search on contact fields — ILIKE or pg_trgm fuzzy match if query != "" { diff --git a/internal/repository/search_repo_test.go b/internal/repository/search_repo_test.go index 01958afe..919f9e64 100644 --- a/internal/repository/search_repo_test.go +++ b/internal/repository/search_repo_test.go @@ -271,6 +271,44 @@ func TestSearchRepo_SearchContacts_EmailMatch(t *testing.T) { assert.NotEmpty(t, results) } +func TestSearchRepo_SearchContacts_ResolvedScope(t *testing.T) { + skipIfSQLite(t) + db := setupTestDB(t) + repo := NewSearchRepo(db) + + account := createTestAccountForSearch(t, db) + resolved := createTestContactForSearch(t, db, account.ID, "Scope Resolved", "scope@example.com", "") + unresolved := createTestContactForSearch(t, db, account.ID, "Scope Anonymous", "", "") + + filter := &search.SearchFilter{Page: 1, PerPage: 25, ContactResolvedScope: true} + results, total, err := repo.SearchContacts(context.Background(), account.ID, "Scope", filter) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Len(t, results, 1) + assert.Equal(t, resolved.ID, results[0].ID) + assert.NotEqual(t, unresolved.ID, results[0].ID) +} + +func TestSearchRepo_SearchContacts_ResolvedScopeCRMV2(t *testing.T) { + skipIfSQLite(t) + db := setupTestDB(t) + repo := NewSearchRepo(db) + + account := createTestAccountForSearch(t, db) + require.NoError(t, db.Model(account).Update("feature_flags", `{"crm_v2":true}`).Error) + lead := createTestContactForSearch(t, db, account.ID, "Scope Lead", "", "") + require.NoError(t, db.Model(lead).Update("contact_type", "lead").Error) + customer := createTestContactForSearch(t, db, account.ID, "Scope Customer", "customer@example.com", "") + require.NoError(t, db.Model(customer).Update("contact_type", "customer").Error) + + filter := &search.SearchFilter{Page: 1, PerPage: 25, ContactResolvedScope: true, ContactCRMV2: repo.AccountFeatureEnabled(context.Background(), account.ID, "crm_v2")} + results, total, err := repo.SearchContacts(context.Background(), account.ID, "Scope", filter) + require.NoError(t, err) + assert.Equal(t, int64(1), total) + require.Len(t, results, 1) + assert.Equal(t, lead.ID, results[0].ID) +} + // ========== SearchArticles DB tests (PG only for ILIKE) ========== func TestSearchRepo_SearchArticles_TitleMatch(t *testing.T) { @@ -377,4 +415,4 @@ func TestSearchFilter_IsTrigram_Unit(t *testing.T) { sf := &search.SearchFilter{SearchMode: search.SearchModeTrigram} assert.True(t, sf.IsTrigram()) }) -} \ No newline at end of file +} diff --git a/internal/search/engine.go b/internal/search/engine.go index 51ef97f0..3a0d0218 100644 --- a/internal/search/engine.go +++ b/internal/search/engine.go @@ -38,32 +38,34 @@ type SearchEngine interface { // SearchDocument is the normalized payload stored in Meilisearch. Data keeps the // entity payload available for Chatwoot-compatible frontend responses. type SearchDocument struct { - UID string `json:"uid"` - ID uint `json:"id"` - Type SearchResultType `json:"type"` - AccountID uint `json:"account_id"` - Title string `json:"title,omitempty"` - Content string `json:"content,omitempty"` - Snippet string `json:"snippet,omitempty"` - Status string `json:"status,omitempty"` - Priority string `json:"priority,omitempty"` - MessageType string `json:"message_type,omitempty"` - SenderType string `json:"sender_type,omitempty"` - SenderID *uint `json:"sender_id,omitempty"` - ContentType string `json:"content_type,omitempty"` - Private bool `json:"private"` - ContactSource string `json:"contact_source,omitempty"` - Labels []string `json:"labels,omitempty"` - AssigneeID *uint `json:"assignee_id,omitempty"` - TeamID *uint `json:"team_id,omitempty"` - InboxID *uint `json:"inbox_id,omitempty"` - ContactID *uint `json:"contact_id,omitempty"` - ConversationID *uint `json:"conversation_id,omitempty"` - PortalID *uint `json:"portal_id,omitempty"` - Locale string `json:"locale,omitempty"` - CreatedAtTS int64 `json:"created_at_ts"` - UpdatedAtTS int64 `json:"updated_at_ts"` - Data map[string]interface{} `json:"data,omitempty"` + UID string `json:"uid"` + ID uint `json:"id"` + Type SearchResultType `json:"type"` + AccountID uint `json:"account_id"` + Title string `json:"title,omitempty"` + Content string `json:"content,omitempty"` + Snippet string `json:"snippet,omitempty"` + Status string `json:"status,omitempty"` + Priority string `json:"priority,omitempty"` + MessageType string `json:"message_type,omitempty"` + SenderType string `json:"sender_type,omitempty"` + SenderID *uint `json:"sender_id,omitempty"` + ContentType string `json:"content_type,omitempty"` + Private bool `json:"private"` + ContactSource string `json:"contact_source,omitempty"` + ContactType string `json:"contact_type,omitempty"` + ContactHasDetails bool `json:"contact_has_details"` + Labels []string `json:"labels,omitempty"` + AssigneeID *uint `json:"assignee_id,omitempty"` + TeamID *uint `json:"team_id,omitempty"` + InboxID *uint `json:"inbox_id,omitempty"` + ContactID *uint `json:"contact_id,omitempty"` + ConversationID *uint `json:"conversation_id,omitempty"` + PortalID *uint `json:"portal_id,omitempty"` + Locale string `json:"locale,omitempty"` + CreatedAtTS int64 `json:"created_at_ts"` + UpdatedAtTS int64 `json:"updated_at_ts"` + Data map[string]interface{} `json:"data,omitempty"` } func (d *SearchDocument) ensureUID() { @@ -181,17 +183,20 @@ func MessageDocument(msg model.Message) SearchDocument { } func ContactDocument(contact model.Contact) SearchDocument { + content := strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " ")) doc := SearchDocument{ - ID: contact.ID, - Type: ResultTypeContact, - AccountID: contact.AccountID, - Title: contact.Name, - Content: strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " ")), - Snippet: contactSnippet(&contact, ""), - ContactSource: contact.ContactType, - CreatedAtTS: timestamp(contact.CreatedAt), - UpdatedAtTS: timestamp(contact.UpdatedAt), - Data: map[string]interface{}{"contact": contact}, + ID: contact.ID, + Type: ResultTypeContact, + AccountID: contact.AccountID, + Title: contact.Name, + Content: content, + Snippet: contactSnippet(&contact, ""), + ContactSource: contact.ContactType, + ContactType: contact.ContactType, + ContactHasDetails: content != "", + CreatedAtTS: timestamp(contact.CreatedAt), + UpdatedAtTS: timestamp(contact.UpdatedAt), + Data: map[string]interface{}{"contact": contact}, } doc.ensureUID() return doc diff --git a/internal/search/engine_db.go b/internal/search/engine_db.go index 96152703..40a1d1c1 100644 --- a/internal/search/engine_db.go +++ b/internal/search/engine_db.go @@ -19,7 +19,7 @@ func NewSearchEngineDB(repo SearchRepoInterface) *SearchEngineDB { } func (e *SearchEngineDB) Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) { - return (&SearchService{searchRepo: e.repo}).GlobalSearch(ctx, accountID, query, filter) + return NewSearchService(e.repo).GlobalSearch(ctx, accountID, query, filter) } func (e *SearchEngineDB) IndexDocument(ctx context.Context, doc SearchDocument) error { diff --git a/internal/search/engine_meili.go b/internal/search/engine_meili.go index 122e51a1..9b3ea91b 100644 --- a/internal/search/engine_meili.go +++ b/internal/search/engine_meili.go @@ -174,7 +174,7 @@ func (e *MeiliSearchEngine) ensureIndex(ctx context.Context, docType SearchResul func (e *MeiliSearchEngine) applySettings(ctx context.Context, docType SearchResultType) error { settings := map[string]interface{}{ "searchableAttributes": []string{"title", "content", "snippet", "status", "priority", "labels", "locale"}, - "filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"}, + "filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "contact_type", "contact_has_details", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"}, "sortableAttributes": []string{"created_at_ts", "updated_at_ts", "id"}, } resp, err := e.client.R(). @@ -220,8 +220,17 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul parts = append(parts, fmt.Sprintf("private = %t", *filter.Private)) } } - if docType == ResultTypeContact && filter.ContactSource != "" { - parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource)) + if docType == ResultTypeContact { + if filter.ContactResolvedScope { + if filter.ContactCRMV2 { + parts = append(parts, `contact_type = "lead"`) + } else { + parts = append(parts, "contact_has_details = true") + } + } + if filter.ContactSource != "" { + parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource)) + } } if docType == ResultTypeArticle || docType == ResultTypeHelpCenter { if filter.PortalID != nil { diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go index 8cf2e95b..ef0eabb7 100644 --- a/internal/search/engine_test.go +++ b/internal/search/engine_test.go @@ -53,6 +53,24 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) { assert.Equal(t, []string{"billing", "urgent"}, doc.Labels) } +func TestContactDocumentSetsResolvedScopeFields(t *testing.T) { + doc := ContactDocument(model.Contact{ + Base: model.Base{ID: 5}, + AccountID: 2, + Name: "Grace Hopper", + Email: "grace@example.com", + ContactType: "lead", + }) + + assert.Equal(t, ResultTypeContact, doc.Type) + assert.Equal(t, "lead", doc.ContactType) + assert.Equal(t, "lead", doc.ContactSource) + assert.True(t, doc.ContactHasDetails) + + anonymous := ContactDocument(model.Contact{Base: model.Base{ID: 6}, AccountID: 2, Name: "Anonymous"}) + assert.False(t, anonymous.ContactHasDetails) +} + func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -77,6 +95,40 @@ func TestMeiliSearchEngine_SearchSendsScopedFilter(t *testing.T) { assert.Equal(t, "ada", requestBody["q"]) } +func TestMeiliSearchEngine_SearchSendsResolvedContactFilter(t *testing.T) { + var requestBody map[string]interface{} + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "/indexes/gochat_contacts/search", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) + return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil + }) + + engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) + engine.client.SetTransport(transport) + filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}, ContactResolvedScope: true} + _, err := engine.Search(context.Background(), 42, "ada", filter) + + require.NoError(t, err) + assert.Equal(t, "account_id = 42 AND contact_has_details = true", requestBody["filter"]) +} + +func TestMeiliSearchEngine_SearchSendsCRMV2ResolvedContactFilter(t *testing.T) { + var requestBody map[string]interface{} + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "/indexes/gochat_contacts/search", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) + return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil + }) + + engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) + engine.client.SetTransport(transport) + filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeContact}, ContactResolvedScope: true, ContactCRMV2: true} + _, err := engine.Search(context.Background(), 42, "ada", filter) + + require.NoError(t, err) + assert.Equal(t, `account_id = 42 AND contact_type = "lead"`, requestBody["filter"]) +} + func TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter(t *testing.T) { var requestBody map[string]interface{} transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { diff --git a/internal/search/search_filter.go b/internal/search/search_filter.go index 09ed07f5..a82bf844 100644 --- a/internal/search/search_filter.go +++ b/internal/search/search_filter.go @@ -38,6 +38,9 @@ type SearchFilter struct { // Contact-specific filters ContactSource string `form:"contact_source"` // email, phone, website, api, etc. + // Chatwoot global search applies Contact.resolved_contacts to contact results. + ContactResolvedScope bool `form:"-"` + ContactCRMV2 bool `form:"-"` // Message-specific filters MessageType string `form:"message_type"` // incoming, outgoing, activity, template diff --git a/internal/search/search_service.go b/internal/search/search_service.go index 7c61d529..975be9d3 100644 --- a/internal/search/search_service.go +++ b/internal/search/search_service.go @@ -13,19 +13,31 @@ import ( // and contacts with advanced filtering and sorting. // Reference: Chatwoot GlobalSearchService — cross-entity search with filter params. type SearchService struct { - searchRepo SearchRepoInterface - engine SearchEngine + searchRepo SearchRepoInterface + engine SearchEngine + featureResolver accountFeatureResolver +} + +type accountFeatureResolver interface { + AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool } // NewSearchService creates a new SearchService. func NewSearchService(searchRepo SearchRepoInterface) *SearchService { - return &SearchService{searchRepo: searchRepo} + return &SearchService{searchRepo: searchRepo, featureResolver: searchFeatureResolver(searchRepo)} } // NewSearchServiceWithEngine creates a SearchService backed by an explicit // SearchEngine. The repository remains available for db fallback and legacy tests. func NewSearchServiceWithEngine(engine SearchEngine, fallbackRepo SearchRepoInterface) *SearchService { - return &SearchService{searchRepo: fallbackRepo, engine: engine} + return &SearchService{searchRepo: fallbackRepo, engine: engine, featureResolver: searchFeatureResolver(fallbackRepo)} +} + +func searchFeatureResolver(repo SearchRepoInterface) accountFeatureResolver { + if resolver, ok := repo.(accountFeatureResolver); ok { + return resolver + } + return nil } // GlobalSearch performs a unified search across all searchable entity types @@ -56,8 +68,10 @@ func (s *SearchService) GlobalSearch(ctx context.Context, accountID uint, query } if s.engine != nil { + filter = s.withContactResolvedScope(ctx, accountID, filter) return s.engine.Search(ctx, accountID, query, filter) } + filter = s.withContactResolvedScope(ctx, accountID, filter) var allResults []SearchResult byType := map[string]int64{} @@ -256,6 +270,12 @@ func (s *SearchService) SearchContacts(ctx context.Context, accountID uint, quer return results, total, nil } +// SearchResolvedContacts matches Chatwoot SearchService#filter_contacts, which +// applies Contact.resolved_contacts to global/entity search contact results. +func (s *SearchService) SearchResolvedContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]SearchResult, int64, error) { + return s.SearchContacts(ctx, accountID, query, s.withContactResolvedScope(ctx, accountID, filter)) +} + // 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 { @@ -327,6 +347,15 @@ func cloneSearchFilter(filter *SearchFilter) *SearchFilter { return &clone } +func (s *SearchService) withContactResolvedScope(ctx context.Context, accountID uint, filter *SearchFilter) *SearchFilter { + clone := cloneSearchFilter(filter) + clone.ContactResolvedScope = true + if s != nil && s.featureResolver != nil { + clone.ContactCRMV2 = s.featureResolver.AccountFeatureEnabled(ctx, accountID, "crm_v2") + } + return clone +} + func (s *SearchService) IndexDocument(ctx context.Context, doc SearchDocument) error { if s.engine == nil { return nil diff --git a/internal/search/search_service_test.go b/internal/search/search_service_test.go index 006d5fbd..aa8461fb 100644 --- a/internal/search/search_service_test.go +++ b/internal/search/search_service_test.go @@ -22,9 +22,10 @@ type mockSearchRepo struct { msgCount int64 msgErr error - contacts []model.Contact - contactCount int64 - contactErr error + contacts []model.Contact + contactCount int64 + contactErr error + contactFilter *SearchFilter companies []model.Company companyCount int64 @@ -33,6 +34,8 @@ type mockSearchRepo struct { articles []model.Article articleCount int64 articleErr error + + features map[string]bool } func (m *mockSearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Conversation, int64, error) { @@ -44,9 +47,14 @@ func (m *mockSearchRepo) SearchMessages(ctx context.Context, accountID uint, que } func (m *mockSearchRepo) SearchContacts(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Contact, int64, error) { + m.contactFilter = filter return m.contacts, m.contactCount, m.contactErr } +func (m *mockSearchRepo) AccountFeatureEnabled(ctx context.Context, accountID uint, flag string) bool { + return m.features[flag] +} + func (m *mockSearchRepo) SearchCompanies(ctx context.Context, accountID uint, query string, filter *SearchFilter) ([]model.Company, int64, error) { return m.companies, m.companyCount, m.companyErr } @@ -201,6 +209,36 @@ func TestGlobalSearch_AllTypes(t *testing.T) { assert.Equal(t, int64(1), resp.ByType["article"]) } +func TestGlobalSearch_AppliesResolvedContactScope(t *testing.T) { + repo := &mockSearchRepo{} + repo.contacts = []model.Contact{makeContact(20, 1, "John Doe", "john@example.com", "")} + repo.contactCount = 1 + + svc := NewSearchService(repo) + filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeContact}} + + _, err := svc.GlobalSearch(context.Background(), 1, "john", filter) + require.NoError(t, err) + require.NotNil(t, repo.contactFilter) + assert.True(t, repo.contactFilter.ContactResolvedScope) + assert.False(t, repo.contactFilter.ContactCRMV2) +} + +func TestGlobalSearch_AppliesCRMV2ResolvedContactScope(t *testing.T) { + repo := &mockSearchRepo{features: map[string]bool{"crm_v2": true}} + repo.contacts = []model.Contact{makeContact(20, 1, "Lead", "", "")} + repo.contactCount = 1 + + svc := NewSearchService(repo) + filter := &SearchFilter{Page: 1, PerPage: 25, Types: []SearchResultType{ResultTypeContact}} + + _, err := svc.GlobalSearch(context.Background(), 1, "lead", filter) + require.NoError(t, err) + require.NotNil(t, repo.contactFilter) + assert.True(t, repo.contactFilter.ContactResolvedScope) + assert.True(t, repo.contactFilter.ContactCRMV2) +} + func TestGlobalSearch_RepoErrorGraceful(t *testing.T) { repo := &mockSearchRepo{} repo.convErr = fmt.Errorf("db error")