feat(contacts): align resolved scope

This commit is contained in:
2026-06-07 05:25:38 +08:00
parent 781ed352dd
commit 0baee8e47a
4 changed files with 174 additions and 8 deletions
+9 -5
View File
@@ -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.
@@ -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{}{
+45
View File
@@ -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)
+71 -1
View File
@@ -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)
}
}