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
@@ -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)
}
}