From 0baee8e47aa1d4862f9d0871fcf7a24af6de440d Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 05:25:38 +0800 Subject: [PATCH] feat(contacts): align resolved scope --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 ++-- .../api/v1/contact_handler_crud_test.go | 51 ++++++++++++- internal/repository/contact_repo.go | 45 ++++++++++++ internal/repository/contact_repo_test.go | 72 ++++++++++++++++++- 4 files changed, 174 insertions(+), 8 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 7f5c967c..eab3618b 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.68 contact filter payload parity, prepared as `feat(contacts): align filter payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): align filter payloads`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Contacts::FilterService` payload behavior for standard, additional, label, date, and custom contact attributes. No route artifacts change. +- 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. - 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 filter payload conditions now follow Chatwoot `Contacts::FilterService` for `name`, `email`, `phone_number`, `identifier`, `blocked`, additional attributes `country_code`, `city`, `company_name`, contact labels, `created_at`, `last_activity_at`, query-operator validation, and account-scoped contact custom attributes with null-inclusive `not_equal_to`. Existing 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 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: 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. - `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.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.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. | @@ -231,6 +232,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.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. | | P3.66 conversation filter attribute 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/helpers/filters/filter_helper.rb#handle_additional_attributes`, `reference/chatwoot/app/services/filters/custom_attribute_filter_helper.rb`, `reference/chatwoot/app/services/filter_service.rb#filter_operation`, `reference/chatwoot/app/services/conversations/filter_service.rb` | Conversation filter payload now supports additional-attribute keys `browser_language`, `conversation_language`, `referer`, and `mail_subject` with Chatwoot operator sets, dialect-aware JSON extraction, case-insensitive contains checks, and account-scoped conversation custom attribute definitions. Custom `not_equal_to` includes conversations where the custom attribute key is absent, matching Chatwoot's appended `IS NULL` behavior. | Review by `feat(conversations): align filter attributes`; focused filter handler tests assert additional-attribute equality, additional-attribute contains, and custom-attribute `not_equal_to` null inclusion; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | @@ -324,6 +326,7 @@ 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(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. | @@ -2653,3 +2656,4 @@ Verification milestone gates: - 2026-06-07: P3.66 conversation filter attribute checkpoint prepared as `feat(conversations): align filter attributes`; audited Chatwoot `filter_keys.yml`, `Filters::FilterHelper#handle_additional_attributes`, `Filters::CustomAttributeFilterHelper`, `FilterService#filter_operation`, and `Conversations::FilterService`. GoChat payload filters now support conversation additional attributes (`browser_language`, `conversation_language`, `referer`, `mail_subject`) and account-scoped conversation custom attributes, including case-normalized text/list/link comparisons and `not_equal_to` matching absent custom-attribute keys. Focused filter handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 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. diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index 3ca707b5..bc7b6e2c 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -229,6 +229,52 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Success() { s.Equal(float64(1), metaMap["current_page"]) } +func (s *ContactHandlerCRUDTestSuite) TestList_ChatwootResolvedContactsScope() { + anonymous := &model.Contact{AccountID: s.account.ID, Name: "Anonymous Visitor"} + s.Require().NoError(s.db.Create(anonymous).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + fmt.Sprintf("/api/v1/accounts/%d/contacts?page=1&page_size=25", s.account.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + 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"]) + meta := resp["meta"].(map[string]interface{}) + s.Equal(float64(1), meta["count"]) +} + +func (s *ContactHandlerCRUDTestSuite) TestList_ChatwootResolvedContactsCRMV2Scope() { + s.account.FeatureFlags = `{"crm_v2":true}` + s.Require().NoError(s.db.Save(s.account).Error) + s.contact.ContactType = "lead" + s.Require().NoError(s.db.Save(s.contact).Error) + + leadOnly := &model.Contact{AccountID: s.account.ID, Name: "Lead Only", ContactType: "lead"} + s.Require().NoError(s.db.Create(leadOnly).Error) + customer := &model.Contact{AccountID: s.account.ID, Name: "Customer", Email: "customer@example.com", ContactType: "customer"} + s.Require().NoError(s.db.Create(customer).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + fmt.Sprintf("/api/v1/accounts/%d/contacts?page=1&page_size=25", s.account.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + s.Len(payload, 2) + ids := []float64{payload[0].(map[string]interface{})["id"].(float64), payload[1].(map[string]interface{})["id"].(float64)} + s.ElementsMatch([]float64{float64(s.contact.ID), float64(leadOnly.ID)}, ids) + meta := resp["meta"].(map[string]interface{}) + s.Equal(float64(2), meta["count"]) +} + func (s *ContactHandlerCRUDTestSuite) TestList_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/v1/accounts/abc/contacts?page=1&page_size=25", nil) @@ -247,6 +293,7 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() { c := &model.Contact{ AccountID: s.account.ID, Name: fmt.Sprintf("Contact %d", i), + Email: fmt.Sprintf("contact-%d@example.com", i), } s.Require().NoError(s.db.Create(c).Error) } @@ -930,9 +977,9 @@ func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadCustomAttributeN s.contact.CustomAttributes = datatypes.JSON(`{"customer_type":"platinum"}`) s.Require().NoError(s.db.Save(s.contact).Error) - regular := &model.Contact{AccountID: s.account.ID, Name: "Regular User", CustomAttributes: datatypes.JSON(`{"customer_type":"regular"}`)} + regular := &model.Contact{AccountID: s.account.ID, Name: "Regular User", Email: "regular@example.com", CustomAttributes: datatypes.JSON(`{"customer_type":"regular"}`)} s.Require().NoError(s.db.Create(regular).Error) - missing := &model.Contact{AccountID: s.account.ID, Name: "Missing User", CustomAttributes: datatypes.JSON(`{}`)} + missing := &model.Contact{AccountID: s.account.ID, Name: "Missing User", Email: "missing@example.com", CustomAttributes: datatypes.JSON(`{}`)} s.Require().NoError(s.db.Create(missing).Error) body, _ := json.Marshal(map[string]interface{}{ diff --git a/internal/repository/contact_repo.go b/internal/repository/contact_repo.go index 78da6c80..a901815c 100644 --- a/internal/repository/contact_repo.go +++ b/internal/repository/contact_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "encoding/json" "errors" "fmt" "strconv" @@ -48,6 +49,7 @@ func (r *ContactRepo) FindByAccount(ctx context.Context, accountID uint, offset, labelFilter := firstContactLabelFilter(labels) base := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID) + base = r.applyResolvedContactsScope(ctx, base, accountID) base = applyContactLabelFilter(base, accountID, labelFilter) countDB := base @@ -145,6 +147,47 @@ func applyContactLabelFilter(q *gorm.DB, accountID uint, labels []string) *gorm. Where("contact_labels.account_id = ? AND tags.account_id = ? AND tags.name IN ?", accountID, accountID, labels) } +func (r *ContactRepo) applyResolvedContactsScope(ctx context.Context, q *gorm.DB, accountID uint) *gorm.DB { + if r.contactCRMV2Enabled(ctx, accountID) { + return q.Where("contacts.contact_type = ?", "lead") + } + return q.Where("(contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> '')") +} + +func (r *ContactRepo) contactCRMV2Enabled(ctx context.Context, accountID uint) 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, "crm_v2") +} + +func contactFeatureFlagEnabled(raw, flag string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + objectFlags := map[string]bool{} + if err := json.Unmarshal([]byte(raw), &objectFlags); err == nil { + return objectFlags[flag] + } + arrayFlags := []string{} + if err := json.Unmarshal([]byte(raw), &arrayFlags); err == nil { + for _, item := range arrayFlags { + if item == flag { + return true + } + } + return false + } + for _, item := range strings.Split(raw, ",") { + if strings.TrimSpace(item) == flag { + return true + } + } + return false +} + // resolveContactSort maps a sort parameter to a SQL ORDER BY clause. // Supported values: "name", "email", "created_at", "last_activity_at" (default). // Direction defaults to DESC for date fields, ASC for name/email. @@ -250,6 +293,7 @@ func (r *ContactRepo) FindForExport(ctx context.Context, accountID uint, params func (r *ContactRepo) exportQuery(ctx context.Context, accountID uint, params ContactFilterParams) *gorm.DB { q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID) + q = r.applyResolvedContactsScope(ctx, q, accountID) if params.ContactType != "" { q = q.Where("contacts.contact_type = ?", params.ContactType) } @@ -346,6 +390,7 @@ func (r *ContactRepo) Filter(ctx context.Context, accountID uint, params Contact var total int64 q := r.db.WithContext(ctx).Model(&model.Contact{}).Where("contacts.account_id = ?", accountID) + q = r.applyResolvedContactsScope(ctx, q, accountID) usesDistinct := false if len(params.Payload) > 0 { advancedQuery, err := r.applyContactFilterPayload(ctx, q, accountID, params.Payload) diff --git a/internal/repository/contact_repo_test.go b/internal/repository/contact_repo_test.go index 716b70ec..e8943e33 100644 --- a/internal/repository/contact_repo_test.go +++ b/internal/repository/contact_repo_test.go @@ -121,6 +121,52 @@ func TestContactRepo_FindByAccount(t *testing.T) { assert.Len(t, contacts, 5) } +func TestContactRepo_FindByAccount_ResolvedContacts(t *testing.T) { + db := setupTestDB(t) + repo := NewContactRepo(db) + + account := &model.Account{Name: "ContactResolvedOrg", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + + withEmail := createTestContact(t, db, account.ID, "Email Lead", "lead@example.com") + withPhone := createTestContact(t, db, account.ID, "Phone Lead", "") + require.NoError(t, db.Model(withPhone).Update("phone_number", "+1234567890").Error) + withIdentifier := createTestContact(t, db, account.ID, "Identifier Lead", "") + require.NoError(t, db.Model(withIdentifier).Update("identifier", "external-1").Error) + createTestContact(t, db, account.ID, "Anonymous Visitor", "") + + contacts, total, err := repo.FindByAccount(context.Background(), account.ID, 0, 10, "") + assert.NoError(t, err) + assert.Equal(t, int64(3), total) + ids := contactIDs(contacts) + assert.Contains(t, ids, withEmail.ID) + assert.Contains(t, ids, withPhone.ID) + assert.Contains(t, ids, withIdentifier.ID) +} + +func TestContactRepo_FindByAccount_ResolvedContactsCRMV2(t *testing.T) { + db := setupTestDB(t) + repo := NewContactRepo(db) + + account := &model.Account{Name: "ContactCRMV2Org", Locale: "en", Active: true, FeatureFlags: `{"crm_v2":true}`} + require.NoError(t, db.Create(account).Error) + + leadWithEmail := createTestContact(t, db, account.ID, "Lead With Email", "lead@example.com") + require.NoError(t, db.Model(leadWithEmail).Update("contact_type", "lead").Error) + leadWithoutDetails := createTestContact(t, db, account.ID, "Lead Without Details", "") + require.NoError(t, db.Model(leadWithoutDetails).Update("contact_type", "lead").Error) + customerWithEmail := createTestContact(t, db, account.ID, "Customer", "customer@example.com") + require.NoError(t, db.Model(customerWithEmail).Update("contact_type", "customer").Error) + + contacts, total, err := repo.FindByAccount(context.Background(), account.ID, 0, 10, "") + assert.NoError(t, err) + assert.Equal(t, int64(2), total) + ids := contactIDs(contacts) + assert.Contains(t, ids, leadWithEmail.ID) + assert.Contains(t, ids, leadWithoutDetails.ID) + assert.NotContains(t, ids, customerWithEmail.ID) +} + func TestContactRepo_FindByAccount_Empty(t *testing.T) { db := setupTestDB(t) repo := NewContactRepo(db) @@ -312,6 +358,30 @@ func TestContactRepo_FindAllForExport(t *testing.T) { assert.Equal(t, c3.ID, contacts[2].ID) } +func TestContactRepo_FindAllForExport_UsesResolvedContacts(t *testing.T) { + db := setupTestDB(t) + repo := NewContactRepo(db) + + account := &model.Account{Name: "ContactExportResolvedOrg", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + + resolved := createTestContact(t, db, account.ID, "Resolved Export", "resolved@example.com") + createTestContact(t, db, account.ID, "Anonymous Export", "") + + contacts, err := repo.FindAllForExport(context.Background(), account.ID) + assert.NoError(t, err) + assert.Len(t, contacts, 1) + assert.Equal(t, resolved.ID, contacts[0].ID) +} + +func contactIDs(contacts []model.Contact) []uint { + ids := make([]uint, 0, len(contacts)) + for _, contact := range contacts { + ids = append(ids, contact.ID) + } + return ids +} + func TestContactRepo_FindAllForExport_Empty(t *testing.T) { db := setupTestDB(t) repo := NewContactRepo(db) @@ -360,4 +430,4 @@ func TestContactRepo_DeleteCustomAttributes_NotFound(t *testing.T) { // GORM's Update with Where clause on nonexistent record returns nil error // (it just affects 0 rows), so we verify it doesn't crash and returns no error assert.NoError(t, err) -} \ No newline at end of file +}