feat(contacts): align filter payloads

This commit is contained in:
2026-06-07 05:11:35 +08:00
parent 576239b842
commit 781ed352dd
4 changed files with 543 additions and 12 deletions
@@ -1080,6 +1080,10 @@ func (h *ContactHandler) Filter(c *gin.Context) {
offset := (page - 1) * perPage
contacts, total, svcErr := h.svc.Filter(c.Request.Context(), accountID, params, offset, perPage)
if svcErr != nil {
if len(params.Payload) > 0 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
return
}
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to filter contacts"})
return
}
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"gorm.io/datatypes"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -52,6 +53,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
&model.Contact{},
&model.Tag{},
&model.ContactLabel{},
&model.CustomAttributeDefinition{},
&model.ContactExport{},
&model.DataImport{},
&model.Notification{},
@@ -89,6 +91,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
s.router.GET("/api/v1/accounts/:id/contacts", s.handler.List)
s.router.GET("/api/v1/accounts/:id/contacts/search", s.handler.Search)
s.router.GET("/api/v1/accounts/:id/contacts/active", s.handler.Active)
s.router.POST("/api/v1/accounts/:id/contacts/filter", s.handler.Filter)
s.router.POST("/api/v1/accounts/:id/contacts/export", s.handler.ExportRequest)
s.router.GET("/api/v1/accounts/:id/contacts/export/:export_id/download", s.handler.DownloadExport)
s.router.POST("/api/v1/accounts/:id/contacts/import", s.handler.Import)
@@ -137,6 +140,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
func (s *ContactHandlerCRUDTestSuite) SetupTest() {
s.db.Exec("DELETE FROM contact_notes")
s.db.Exec("DELETE FROM contact_labels")
s.db.Exec("DELETE FROM custom_attribute_definitions")
s.db.Exec("DELETE FROM tags")
s.db.Exec("DELETE FROM contact_exports")
s.db.Exec("DELETE FROM data_imports")
@@ -854,6 +858,119 @@ func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() {
s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"])
}
func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadStandardAndAdditionalAttributes() {
s.contact.PhoneNumber = "+1234567890"
s.contact.AdditionalAttributes = datatypes.JSON(`{"country_code":"uk","city":"London"}`)
s.Require().NoError(s.db.Save(s.contact).Error)
other := &model.Contact{AccountID: s.account.ID, Name: "Other User", Email: "other@example.com", PhoneNumber: "+1987654321", AdditionalAttributes: datatypes.JSON(`{"country_code":"gr","city":"Athens"}`)}
s.Require().NoError(s.db.Create(other).Error)
body, _ := json.Marshal(map[string]interface{}{
"payload": []map[string]interface{}{
{"attribute_key": "email", "filter_operator": "contains", "values": []string{"JANE"}, "query_operator": "AND"},
{"attribute_key": "phone_number", "filter_operator": "equal_to", "values": []string{"1234567890"}, "query_operator": "AND"},
{"attribute_key": "country_code", "filter_operator": "equal_to", "values": []string{"UK"}},
},
})
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)
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)
s.Require().NoError(s.db.Create(&model.ContactLabel{AccountID: s.account.ID, ContactID: s.contact.ID, TagID: tag.ID}).Error)
recentActivity := time.Now().UTC().AddDate(0, 0, -1).Unix()
oldActivity := time.Now().UTC().AddDate(0, 0, -8).Unix()
s.contact.LastActivityAt = &oldActivity
s.Require().NoError(s.db.Save(s.contact).Error)
other := &model.Contact{AccountID: s.account.ID, Name: "Other User", LastActivityAt: &recentActivity}
s.Require().NoError(s.db.Create(other).Error)
body, _ := json.Marshal(map[string]interface{}{
"payload": []map[string]interface{}{
{"attribute_key": "labels", "filter_operator": "is_present", "values": []string{}, "query_operator": "AND"},
{"attribute_key": "last_activity_at", "filter_operator": "days_before", "values": []string{"3"}},
},
})
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)
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_ChatwootPayloadCustomAttributeNotEqualIncludesNull() {
def := &model.CustomAttributeDefinition{
AccountID: s.account.ID,
AttributeName: "customer_type",
AttributeDisplayName: "Customer type",
AttributeType: "list",
AttributeModel: "contact_attribute",
AttributeValues: datatypes.JSON(`["platinum","regular"]`),
}
s.Require().NoError(s.db.Create(def).Error)
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"}`)}
s.Require().NoError(s.db.Create(regular).Error)
missing := &model.Contact{AccountID: s.account.ID, Name: "Missing User", CustomAttributes: datatypes.JSON(`{}`)}
s.Require().NoError(s.db.Create(missing).Error)
body, _ := json.Marshal(map[string]interface{}{
"payload": []map[string]interface{}{
{"attribute_key": "customer_type", "custom_attribute_type": "contact_attribute", "filter_operator": "not_equal_to", "values": []string{"platinum"}},
},
})
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)
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(regular.ID), float64(missing.ID)}, ids)
}
func (s *ContactHandlerCRUDTestSuite) TestFilter_ChatwootPayloadInvalidAttribute() {
body, _ := json.Marshal(map[string]interface{}{
"payload": []map[string]interface{}{
{"attribute_key": "unknown", "filter_operator": "equal_to", "values": []string{"x"}},
},
})
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.StatusUnprocessableEntity, w.Code)
var resp map[string]string
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Contains(resp["error"], "Invalid attribute key - [unknown]")
}
func (s *ContactHandlerCRUDTestSuite) TestDestroyCustomAttributes_SelectedKeysPayload() {
attrs := model.JSONMap{"tier": "gold", "plan": "pro", "vip": true}
s.contact.CustomAttributes = model.ToDatatypesJSON(&attrs)