feat(contacts): align resolved scope
This commit is contained in:
@@ -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{}{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user