feat(conversations): align filter attributes

This commit is contained in:
2026-06-07 04:42:03 +08:00
parent f43ca571c2
commit f8d326ca2a
3 changed files with 310 additions and 35 deletions
+173 -26
View File
@@ -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 {