feat(search): align contact resolved scope
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+41
-36
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user