feat(conversations): align filter attributes
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -88,6 +89,7 @@ func (s *ConversationCrudTestSuite) SetupSuite() {
|
||||
&model.SlaEvent{},
|
||||
&model.CaptainAssistant{},
|
||||
&model.CaptainInbox{},
|
||||
&model.CustomAttributeDefinition{},
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
|
||||
@@ -189,6 +191,7 @@ func (s *ConversationCrudTestSuite) TearDownTest() {
|
||||
s.db.Exec("DELETE FROM sla_policies")
|
||||
s.db.Exec("DELETE FROM captain_inboxes")
|
||||
s.db.Exec("DELETE FROM captain_assistants")
|
||||
s.db.Exec("DELETE FROM custom_attribute_definitions")
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) accountURL() string {
|
||||
@@ -1093,6 +1096,127 @@ func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadQueryOperatorChain
|
||||
assert.ElementsMatch(s.T(), []float64{float64(primaryID), float64(secondaryID)}, ids)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadAdditionalAttribute() {
|
||||
s.testConv.AdditionalAttributes = datatypes.JSON(`{"browser_language":"en"}`)
|
||||
s.Require().NoError(s.db.Save(s.testConv).Error)
|
||||
|
||||
secondary := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "open", AdditionalAttributes: datatypes.JSON(`{"browser_language":"fr"}`), ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(secondary).Error)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"payload": []map[string]interface{}{
|
||||
{
|
||||
"attribute_key": "browser_language",
|
||||
"filter_operator": "equal_to",
|
||||
"values": []string{"en"},
|
||||
},
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/filter", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp struct {
|
||||
Meta struct {
|
||||
AllCount int64 `json:"all_count"`
|
||||
} `json:"meta"`
|
||||
Payload []map[string]interface{} `json:"payload"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), int64(1), resp.Meta.AllCount)
|
||||
s.Require().Len(resp.Payload, 1)
|
||||
assert.Equal(s.T(), float64(s.testConv.ID), resp.Payload[0]["id"])
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadAdditionalAttributeContains() {
|
||||
s.testConv.AdditionalAttributes = datatypes.JSON(`{"mail_subject":"Welcome to Billing"}`)
|
||||
s.Require().NoError(s.db.Save(s.testConv).Error)
|
||||
|
||||
secondary := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "open", AdditionalAttributes: datatypes.JSON(`{"mail_subject":"Shipping update"}`), ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(secondary).Error)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"payload": []map[string]interface{}{
|
||||
{
|
||||
"attribute_key": "mail_subject",
|
||||
"filter_operator": "contains",
|
||||
"values": []string{"billing"},
|
||||
},
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/filter", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp struct {
|
||||
Meta struct {
|
||||
AllCount int64 `json:"all_count"`
|
||||
} `json:"meta"`
|
||||
Payload []map[string]interface{} `json:"payload"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), int64(1), resp.Meta.AllCount)
|
||||
s.Require().Len(resp.Payload, 1)
|
||||
assert.Equal(s.T(), float64(s.testConv.ID), resp.Payload[0]["id"])
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadCustomAttributeNotEqualIncludesNull() {
|
||||
def := &model.CustomAttributeDefinition{
|
||||
AccountID: s.testAccount.ID,
|
||||
AttributeName: "conversation_type",
|
||||
AttributeDisplayName: "Conversation type",
|
||||
AttributeType: "list",
|
||||
AttributeModel: "conversation_attribute",
|
||||
AttributeValues: datatypes.JSON(`["platinum","silver"]`),
|
||||
}
|
||||
s.Require().NoError(s.db.Create(def).Error)
|
||||
|
||||
platinumID := uint(22201)
|
||||
s.testConv.DisplayID = &platinumID
|
||||
s.testConv.CustomAttributes = datatypes.JSON(`{"conversation_type":"platinum"}`)
|
||||
s.Require().NoError(s.db.Save(s.testConv).Error)
|
||||
|
||||
silverID := uint(22202)
|
||||
silver := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, DisplayID: &silverID, Status: "open", CustomAttributes: datatypes.JSON(`{"conversation_type":"silver"}`), ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(silver).Error)
|
||||
|
||||
nilID := uint(22203)
|
||||
missing := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, DisplayID: &nilID, Status: "open", CustomAttributes: datatypes.JSON(`{}`), ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(missing).Error)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"payload": []map[string]interface{}{
|
||||
{
|
||||
"attribute_key": "conversation_type",
|
||||
"custom_attribute_type": "conversation_attribute",
|
||||
"filter_operator": "not_equal_to",
|
||||
"values": []string{"platinum"},
|
||||
},
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/filter", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp struct {
|
||||
Meta struct {
|
||||
AllCount int64 `json:"all_count"`
|
||||
} `json:"meta"`
|
||||
Payload []map[string]interface{} `json:"payload"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), int64(2), resp.Meta.AllCount)
|
||||
s.Require().Len(resp.Payload, 2)
|
||||
ids := []float64{resp.Payload[0]["id"].(float64), resp.Payload[1]["id"].(float64)}
|
||||
assert.ElementsMatch(s.T(), []float64{float64(silverID), float64(nilID)}, ids)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadInvalidQueryOperator() {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"payload": []map[string]interface{}{
|
||||
|
||||
@@ -748,7 +748,7 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
|
||||
|
||||
query := s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
|
||||
if len(params.Payload) > 0 {
|
||||
advancedQuery, err := s.applyConversationFilterPayload(query, params.Payload)
|
||||
advancedQuery, err := s.applyConversationFilterPayload(ctx, query, accountID, params.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -892,42 +892,27 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ConversationService) applyConversationFilterPayload(query *gorm.DB, payload []ConversationFilterCondition) (*gorm.DB, error) {
|
||||
func (s *ConversationService) applyConversationFilterPayload(ctx context.Context, query *gorm.DB, accountID uint, payload []ConversationFilterCondition) (*gorm.DB, error) {
|
||||
clauses := make([]string, 0, len(payload)*2)
|
||||
args := make([]any, 0, len(payload))
|
||||
for _, condition := range payload {
|
||||
attribute := strings.TrimSpace(condition.AttributeKey)
|
||||
operator := strings.TrimSpace(condition.FilterOperator)
|
||||
queryOperator := strings.ToUpper(strings.TrimSpace(condition.QueryOperator))
|
||||
values := conversationFilterStringValues(condition.Values)
|
||||
if queryOperator != "" && queryOperator != "AND" && queryOperator != "OR" {
|
||||
return nil, fmt.Errorf("Query operator must be either \"AND\" or \"OR\".")
|
||||
}
|
||||
|
||||
column, allowedOperators, err := conversationFilterColumn(attribute)
|
||||
clause, clauseArgs, err := s.conversationFilterClause(ctx, query, accountID, condition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
|
||||
return nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("Invalid value for %s", attribute)
|
||||
}
|
||||
|
||||
if len(clauses) > 0 && !conversationFilterIsQueryOperator(clauses[len(clauses)-1]) {
|
||||
clauses = append(clauses, "AND")
|
||||
}
|
||||
switch operator {
|
||||
case "equal_to":
|
||||
clauses = append(clauses, column+" IN ?")
|
||||
args = append(args, values)
|
||||
case "not_equal_to":
|
||||
clauses = append(clauses, column+" NOT IN ?")
|
||||
args = append(args, values)
|
||||
}
|
||||
clauses = append(clauses, clause)
|
||||
args = append(args, clauseArgs...)
|
||||
|
||||
if queryOperator != "" {
|
||||
if queryOperator != "AND" && queryOperator != "OR" {
|
||||
return nil, fmt.Errorf("Query operator must be either \"AND\" or \"OR\".")
|
||||
}
|
||||
clauses = append(clauses, queryOperator)
|
||||
}
|
||||
}
|
||||
@@ -942,6 +927,34 @@ func (s *ConversationService) applyConversationFilterPayload(query *gorm.DB, pay
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func (s *ConversationService) conversationFilterClause(ctx context.Context, query *gorm.DB, accountID uint, condition ConversationFilterCondition) (string, []any, error) {
|
||||
attribute := strings.TrimSpace(condition.AttributeKey)
|
||||
operator := strings.TrimSpace(condition.FilterOperator)
|
||||
values := conversationFilterStringValues(condition.Values)
|
||||
|
||||
column, allowedOperators, err := conversationFilterColumn(attribute)
|
||||
if err == nil {
|
||||
return conversationFilterBuildClause(attribute, column, nil, operator, values, allowedOperators, false)
|
||||
}
|
||||
|
||||
if additional, ok := conversationFilterAdditionalAttribute(attribute); ok {
|
||||
expr, exprArgs := conversationFilterJSONExtract(query, "conversations.additional_attributes", attribute)
|
||||
return conversationFilterBuildClause(attribute, expr, exprArgs, operator, values, additional.allowedOperators, false)
|
||||
}
|
||||
|
||||
def, defErr := s.findConversationFilterCustomAttributeDefinition(ctx, accountID, attribute, condition.CustomAttributeType)
|
||||
if defErr != nil {
|
||||
return "", nil, defErr
|
||||
}
|
||||
if def == nil {
|
||||
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
|
||||
}
|
||||
|
||||
expr, exprArgs := conversationFilterJSONExtract(query, "conversations.custom_attributes", attribute)
|
||||
expr = conversationFilterCustomAttributeExpression(expr, def.AttributeType)
|
||||
return conversationFilterBuildClause(attribute, expr, exprArgs, operator, conversationFilterCustomAttributeValues(values, def.AttributeType), conversationFilterCustomAttributeOperators(def.AttributeType), true)
|
||||
}
|
||||
|
||||
func conversationFilterIsQueryOperator(value string) bool {
|
||||
return value == "AND" || value == "OR"
|
||||
}
|
||||
@@ -949,16 +962,150 @@ func conversationFilterIsQueryOperator(value string) bool {
|
||||
func conversationFilterColumn(attribute string) (string, []string, error) {
|
||||
switch attribute {
|
||||
case "status":
|
||||
return "status", []string{"equal_to", "not_equal_to"}, nil
|
||||
return "conversations.status", []string{"equal_to", "not_equal_to"}, nil
|
||||
case "priority":
|
||||
return "priority", []string{"equal_to", "not_equal_to"}, nil
|
||||
return "conversations.priority", []string{"equal_to", "not_equal_to"}, nil
|
||||
case "display_id":
|
||||
return "display_id", []string{"equal_to", "not_equal_to"}, nil
|
||||
return "conversations.display_id", []string{"equal_to", "not_equal_to"}, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
|
||||
}
|
||||
}
|
||||
|
||||
type conversationAdditionalFilter struct {
|
||||
allowedOperators []string
|
||||
}
|
||||
|
||||
func conversationFilterAdditionalAttribute(attribute string) (conversationAdditionalFilter, bool) {
|
||||
switch attribute {
|
||||
case "browser_language", "conversation_language":
|
||||
return conversationAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to"}}, true
|
||||
case "referer", "mail_subject":
|
||||
return conversationAdditionalFilter{allowedOperators: []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}}, true
|
||||
default:
|
||||
return conversationAdditionalFilter{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ConversationService) findConversationFilterCustomAttributeDefinition(ctx context.Context, accountID uint, attribute, customAttributeType string) (*model.CustomAttributeDefinition, error) {
|
||||
attributeModel := strings.TrimSpace(customAttributeType)
|
||||
if attributeModel == "" {
|
||||
attributeModel = "conversation_attribute"
|
||||
}
|
||||
if attributeModel == "conversation" {
|
||||
attributeModel = "conversation_attribute"
|
||||
}
|
||||
if attributeModel != "conversation_attribute" {
|
||||
return nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
|
||||
}
|
||||
|
||||
var def model.CustomAttributeDefinition
|
||||
err := s.repo.DB().WithContext(ctx).
|
||||
Where("account_id = ? AND attribute_name = ? AND attribute_model IN ?", accountID, attribute, []string{"conversation_attribute", "conversation"}).
|
||||
First(&def).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &def, nil
|
||||
}
|
||||
|
||||
func conversationFilterBuildClause(attribute, expression string, expressionArgs []any, operator string, values []string, allowedOperators []string, includeNullForNotEqual bool) (string, []any, error) {
|
||||
if !conversationFilterOperatorAllowed(operator, allowedOperators) {
|
||||
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "", nil, fmt.Errorf("Invalid value for %s", attribute)
|
||||
}
|
||||
|
||||
args := append([]any{}, expressionArgs...)
|
||||
switch operator {
|
||||
case "equal_to":
|
||||
args = append(args, values)
|
||||
return expression + " IN ?", args, nil
|
||||
case "not_equal_to":
|
||||
args = append(args, values)
|
||||
if includeNullForNotEqual {
|
||||
args = append(args, expressionArgs...)
|
||||
return "(" + expression + " NOT IN ? OR " + expression + " IS NULL)", args, nil
|
||||
}
|
||||
return expression + " NOT IN ?", args, nil
|
||||
case "contains":
|
||||
clause, likeArgs := conversationFilterLikeClause(expression, expressionArgs, values, false)
|
||||
return clause, likeArgs, nil
|
||||
case "does_not_contain":
|
||||
clause, likeArgs := conversationFilterLikeClause(expression, expressionArgs, values, true)
|
||||
return clause, likeArgs, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ","))
|
||||
}
|
||||
}
|
||||
|
||||
func conversationFilterLikeClause(expression string, expressionArgs []any, values []string, negate bool) (string, []any) {
|
||||
clauses := make([]string, 0, len(values))
|
||||
args := make([]any, 0, len(values)*(len(expressionArgs)+1))
|
||||
operator := "LIKE"
|
||||
joiner := " OR "
|
||||
if negate {
|
||||
operator = "NOT LIKE"
|
||||
joiner = " AND "
|
||||
}
|
||||
for _, value := range values {
|
||||
clauses = append(clauses, "LOWER("+expression+") "+operator+" ?")
|
||||
args = append(args, expressionArgs...)
|
||||
args = append(args, "%"+strings.ToLower(value)+"%")
|
||||
}
|
||||
return "(" + strings.Join(clauses, joiner) + ")", args
|
||||
}
|
||||
|
||||
func conversationFilterJSONExtract(query *gorm.DB, column, key string) (string, []any) {
|
||||
dialect := ""
|
||||
if query != nil && query.Dialector != nil {
|
||||
dialect = query.Dialector.Name()
|
||||
}
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
return "json_extract(" + column + ", ?)", []any{"$." + key}
|
||||
case "mysql":
|
||||
return "JSON_UNQUOTE(JSON_EXTRACT(" + column + ", ?))", []any{"$." + key}
|
||||
default:
|
||||
return column + " ->> ?", []any{key}
|
||||
}
|
||||
}
|
||||
|
||||
func conversationFilterCustomAttributeOperators(attributeType string) []string {
|
||||
switch strings.TrimSpace(attributeType) {
|
||||
case "text", "link", "list", "":
|
||||
return []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}
|
||||
default:
|
||||
return []string{"equal_to", "not_equal_to"}
|
||||
}
|
||||
}
|
||||
|
||||
func conversationFilterCustomAttributeValues(values []string, attributeType string) []string {
|
||||
switch strings.TrimSpace(attributeType) {
|
||||
case "text", "link", "list", "":
|
||||
lowered := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
lowered = append(lowered, strings.ToLower(value))
|
||||
}
|
||||
return lowered
|
||||
default:
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
func conversationFilterCustomAttributeExpression(expression, attributeType string) string {
|
||||
switch strings.TrimSpace(attributeType) {
|
||||
case "text", "link", "list", "":
|
||||
return "LOWER(" + expression + ")"
|
||||
default:
|
||||
return expression
|
||||
}
|
||||
}
|
||||
|
||||
func conversationFilterOperatorAllowed(operator string, allowed []string) bool {
|
||||
for _, value := range allowed {
|
||||
if operator == value {
|
||||
|
||||
Reference in New Issue
Block a user