From 46eb7aafbd3c55401fa3ff416d438529d5a26d06 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 06:00:38 +0800 Subject: [PATCH] feat(contacts): filter by referer --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 +++++++---- .../api/v1/contact_handler_crud_test.go | 25 +++++++++++++++++++ internal/repository/contact_repo.go | 2 ++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index d0ca1a5a..5fbbc877 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 P5.8c contact import search indexing parity, prepared as `feat(contacts): index imported contacts`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): index imported contacts`. -- Latest documentation/tooling checkpoint: this tracker update records the search-index side effect for contact import create/update paths. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.71 contact referer filter parity, prepared as `feat(contacts): filter by referer`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): filter by referer`. +- Latest documentation/tooling checkpoint: this tracker update records reused contact filter frontend `referer` support. 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 CSV import now refreshes the contact search index after both imported creates and imported merges, keeping Meilisearch contact documents current for identity fields and `contact_type` changes used by P3.70 resolved-scope search. Existing P3.70 global contact search resolved-scope parity, 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. +- Worktree status at this implementation checkpoint: contact advanced filter now accepts the reused frontend `referer` attribute and matches it against `contacts.additional_attributes.referer` with equality and containment operators, closing the known frontend/reference drift for CRM filters. Existing P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, 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, 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,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.71 contact referer filter parity | Implemented for reused CRM contact advanced filters: GoChat now accepts frontend `referer` payload conditions and filters `contacts.additional_attributes.referer` with equality and containment operators. | Keep in Review; reopen from B12 CRM filter smoke or fresh reference/frontend evidence for exact referer location drift, link case-sensitivity expectations, or export filter reuse gaps. | Focused contact filter handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P5.8c contact import search indexing parity | Implemented for imported contact rows: CSV imports now call the contact search indexer after successful create and after successful duplicate merge/update, so Meilisearch documents stay current for email, phone number, identifier, and `contact_type` fields used by contact/global search. | Keep in Review; reopen from B12 CRM/search smoke or fresh reference evidence for label-aware contact search documents, failed-label partial updates, or durable import worker replay drift. | Focused contact import service 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. | @@ -234,6 +235,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.71 contact referer filter parity | `internal/repository/contact_repo.go`, `internal/handler/api/v1/contact_handler_crud_test.go` | `reference/chatwoot/app/javascript/dashboard/components-next/filter/contactProvider.js`, `reference/chatwoot/app/javascript/dashboard/components-next/filter/helper/filterHelper.js`, `reference/chatwoot/app/services/contacts/filter_service.rb`, `reference/chatwoot/lib/filters/filter_keys.yml` | Contact advanced filters now accept the reused frontend's built-in `referer` contact filter and evaluate it against `contacts.additional_attributes.referer` with `equal_to`, `not_equal_to`, `contains`, and `does_not_contain`. This closes the prior frontend/reference drift where the current dashboard exposed `referer` even though the backend filter key list does not declare it under contacts. | Review by `feat(contacts): filter by referer`; focused handler tests assert `referer contains` matches only contacts whose additional attributes carry the referer URL; full `go test ./...` and `git diff --check`. No route artifacts change. | | P5.8c contact import search indexing parity | `internal/service/contact_service.go`, `internal/service/contact_service_g3_test.go` | `reference/chatwoot/app/controllers/api/v1/accounts/contacts_controller.rb#import`, `reference/chatwoot/app/jobs/data_import_job.rb`, `reference/chatwoot/app/services/data_import/contact_manager.rb`, P3.70 Meilisearch contact document contract | Imported contacts now refresh the contact search index after successful CSV-created rows and after successful duplicate merge/update rows. This keeps Meilisearch contact documents current for identity fields and `contact_type`, which are used by global contact search resolved-scope filters. | Review by `feat(contacts): index imported contacts`; focused import tests assert imported create and merge paths call `IndexContact`; full `go test ./...` and `git diff --check`. No route artifacts change. | | 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. | @@ -330,6 +332,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(contacts): filter by referer` | Advances P3.71 with reused CRM contact filter parity for the frontend-exposed `referer` attribute. Contact advanced filters now evaluate `referer` against `contacts.additional_attributes.referer` with equality and containment operators instead of rejecting the current dashboard payload as an unknown attribute. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestFilter_ContactRefererFromFrontendProvider\|ContactHandlerCRUDTestSuite/TestFilter_ChatwootPayloadStandardAndAdditionalAttributes' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.71 to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, CRM v2 feature-flag serialization, Phase 6 placeholder audit, or B12 live smoke. | | `feat(contacts): index imported contacts` | Advances P5.8c with contact import search-index parity. CSV import create and duplicate-merge/update paths now call the contact search indexer after the row and labels are successfully persisted, keeping Meilisearch contact documents current for identity fields and `contact_type` changes used by P3.70 resolved-scope search. | `go test ./internal/service -run 'TestContactService_ImportCSV_(ImportsValidRows\|MergesDuplicateEmail)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P5.8c to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, CRM v2 feature-flag serialization, label-aware contact search documents, 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. | @@ -2665,3 +2668,4 @@ Verification milestone gates: - 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. - 2026-06-07: P5.8c contact import search indexing checkpoint prepared as `feat(contacts): index imported contacts`; audited the contact import worker path and the P3.70 Meilisearch contact document contract. GoChat CSV import now reindexes contacts after successful imported creates and duplicate merges, keeping search documents current for identity fields and `contact_type`. Focused import service tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.71 contact referer filter checkpoint prepared as `feat(contacts): filter by referer`; audited reused `contactProvider.js`, `filterHelper.js`, `Contacts::FilterService`, and `filter_keys.yml`. GoChat contact advanced filters now accept the frontend-exposed `referer` attribute and match it against `contacts.additional_attributes.referer`, so CRM filter payloads using `contains` no longer fail as unknown attributes. Focused contact filter tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index bc7b6e2c..831469ce 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -933,6 +933,31 @@ func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadStandardAndAddit s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"]) } +func (s *ContactHandlerCRUDTestSuite) TestFilter_ContactRefererFromFrontendProvider() { + s.contact.AdditionalAttributes = datatypes.JSON(`{"referer":"https://docs.example.com/pricing"}`) + s.Require().NoError(s.db.Save(s.contact).Error) + + other := &model.Contact{AccountID: s.account.ID, Name: "Other User", Email: "other@example.com", AdditionalAttributes: datatypes.JSON(`{"referer":"https://blog.example.com/news"}`)} + s.Require().NoError(s.db.Create(other).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "payload": []map[string]interface{}{ + {"attribute_key": "referer", "filter_operator": "contains", "values": []string{"pricing"}}, + }, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/filter", s.account.ID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code, w.Body.String()) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + s.Len(payload, 1) + s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"]) +} + func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadLabelsAndDate() { tag := &model.Tag{AccountID: s.account.ID, Name: "support", Color: "#1f93ff"} s.Require().NoError(s.db.Create(tag).Error) diff --git a/internal/repository/contact_repo.go b/internal/repository/contact_repo.go index a901815c..6c82f94e 100644 --- a/internal/repository/contact_repo.go +++ b/internal/repository/contact_repo.go @@ -545,6 +545,8 @@ func contactFilterAdditionalAttribute(attribute string) (contactAdditionalFilter return contactAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to"}, caseInsensitive: true}, true case "city", "company_name": return contactAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}, caseInsensitive: true}, true + case "referer": + return contactAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}}, true default: return contactAdditionalFilter{}, false }