feat(conversations): align filter payload basics
This commit is contained in:
@@ -503,6 +503,10 @@ func (h *ConversationHandler) Filter(c *gin.Context) {
|
||||
|
||||
result, svcErr := h.conversationSvc.Filter(c.Request.Context(), accountID, userID, req, p.Offset, p.PerPage)
|
||||
if svcErr != nil {
|
||||
if len(req.Payload) > 0 {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1017,6 +1017,83 @@ func (s *ConversationCrudTestSuite) TestFilter_Success() {
|
||||
assert.Len(s.T(), resp.Payload, 1)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadStatus() {
|
||||
resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(resolved).Error)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"payload": []map[string]interface{}{
|
||||
{
|
||||
"attribute_key": "status",
|
||||
"filter_operator": "equal_to",
|
||||
"values": []string{"open"},
|
||||
},
|
||||
},
|
||||
})
|
||||
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)
|
||||
assert.NotContains(s.T(), w.Body.String(), "data")
|
||||
|
||||
var resp struct {
|
||||
Meta struct {
|
||||
AllCount int64 `json:"all_count"`
|
||||
UnassignedCount int64 `json:"unassigned_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)
|
||||
assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount)
|
||||
s.Require().Len(resp.Payload, 1)
|
||||
assert.Equal(s.T(), float64(s.testConv.ID), resp.Payload[0]["id"])
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadInvalidAttribute() {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"payload": []map[string]interface{}{
|
||||
{
|
||||
"attribute_key": "phone_number",
|
||||
"filter_operator": "equal_to",
|
||||
"values": []string{"open"},
|
||||
},
|
||||
},
|
||||
})
|
||||
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.StatusUnprocessableEntity, w.Code)
|
||||
var resp map[string]string
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Contains(s.T(), resp["error"], "Invalid attribute key - [phone_number]")
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadInvalidOperator() {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"payload": []map[string]interface{}{
|
||||
{
|
||||
"attribute_key": "status",
|
||||
"filter_operator": "eq",
|
||||
"values": []string{"open"},
|
||||
},
|
||||
},
|
||||
})
|
||||
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.StatusUnprocessableEntity, w.Code)
|
||||
var resp map[string]string
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), "Invalid operator. The allowed operators for status are [equal_to,not_equal_to].", resp["error"])
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestFilter_InvalidAccountID() {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "open",
|
||||
|
||||
@@ -692,26 +692,35 @@ func (s *ConversationService) Unmute(ctx context.Context, accountID, id uint) (*
|
||||
// Supports all 7 Chatwoot filter dimensions: status, assignee_type, inbox_id, team_id,
|
||||
// labels, conversation_type, and sort_by (with updated_within as an extra filter).
|
||||
type FilterParams struct {
|
||||
Status string `json:"status,omitempty" form:"status" validate:"omitempty,oneof=open resolved pending snoozed all"`
|
||||
Priority string `json:"priority,omitempty" form:"priority" validate:"omitempty,oneof=urgent high medium low none"`
|
||||
AssigneeType string `json:"assignee_type,omitempty" form:"assignee_type" validate:"omitempty,oneof=me unassigned assigned all"`
|
||||
AssigneeID *uint `json:"assignee_id,omitempty" form:"assignee_id"`
|
||||
InboxID *uint `json:"inbox_id,omitempty" form:"inbox_id"`
|
||||
InboxIDs []uint `json:"inbox_ids,omitempty" form:"inbox_ids"` // multi-inbox filtering (Chatwoot: inbox_id can be array)
|
||||
TeamID *uint `json:"team_id,omitempty" form:"team_id"`
|
||||
Labels string `json:"labels,omitempty" form:"labels"`
|
||||
Tags string `json:"tags,omitempty" form:"tags"` // custom tags (Chatwoot: same as labels via ActsAsTaggableOn)
|
||||
ConversationType string `json:"conversation_type,omitempty" form:"conversation_type" validate:"omitempty,oneof=mention participating unattended"`
|
||||
SortBy string `json:"sort_by,omitempty" form:"sort_by" validate:"omitempty,oneof=last_activity_at_asc last_activity_at_desc created_at_asc created_at_desc priority_asc priority_desc waiting_since_asc waiting_since_desc priority_desc_created_at_asc latest sort_on_created_at sort_on_priority sort_on_waiting_since"`
|
||||
UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds
|
||||
Query string `json:"q,omitempty" form:"q"` // 1:1 Chatwoot: filter_by_query — search messages ILIKE
|
||||
SourceID string `json:"source_id,omitempty" form:"source_id"` // 1:1 Chatwoot: filter_by_source_id — contact_inbox.source_id
|
||||
Status string `json:"status,omitempty" form:"status" validate:"omitempty,oneof=open resolved pending snoozed all"`
|
||||
Priority string `json:"priority,omitempty" form:"priority" validate:"omitempty,oneof=urgent high medium low none"`
|
||||
AssigneeType string `json:"assignee_type,omitempty" form:"assignee_type" validate:"omitempty,oneof=me unassigned assigned all"`
|
||||
AssigneeID *uint `json:"assignee_id,omitempty" form:"assignee_id"`
|
||||
InboxID *uint `json:"inbox_id,omitempty" form:"inbox_id"`
|
||||
InboxIDs []uint `json:"inbox_ids,omitempty" form:"inbox_ids"` // multi-inbox filtering (Chatwoot: inbox_id can be array)
|
||||
TeamID *uint `json:"team_id,omitempty" form:"team_id"`
|
||||
Labels string `json:"labels,omitempty" form:"labels"`
|
||||
Tags string `json:"tags,omitempty" form:"tags"` // custom tags (Chatwoot: same as labels via ActsAsTaggableOn)
|
||||
ConversationType string `json:"conversation_type,omitempty" form:"conversation_type" validate:"omitempty,oneof=mention participating unattended"`
|
||||
SortBy string `json:"sort_by,omitempty" form:"sort_by" validate:"omitempty,oneof=last_activity_at_asc last_activity_at_desc created_at_asc created_at_desc priority_asc priority_desc waiting_since_asc waiting_since_desc priority_desc_created_at_asc latest sort_on_created_at sort_on_priority sort_on_waiting_since"`
|
||||
UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds
|
||||
Query string `json:"q,omitempty" form:"q"` // 1:1 Chatwoot: filter_by_query — search messages ILIKE
|
||||
SourceID string `json:"source_id,omitempty" form:"source_id"` // 1:1 Chatwoot: filter_by_source_id — contact_inbox.source_id
|
||||
Payload []ConversationFilterCondition `json:"payload,omitempty" form:"-"`
|
||||
|
||||
// AllowedInboxIDs is populated at runtime — the list of inbox IDs the user can access.
|
||||
// Not populated from request params; set by the service based on user permissions.
|
||||
AllowedInboxIDs []uint
|
||||
}
|
||||
|
||||
type ConversationFilterCondition struct {
|
||||
AttributeKey string `json:"attribute_key"`
|
||||
FilterOperator string `json:"filter_operator"`
|
||||
Values []any `json:"values"`
|
||||
QueryOperator string `json:"query_operator,omitempty"`
|
||||
CustomAttributeType string `json:"custom_attribute_type,omitempty"`
|
||||
}
|
||||
|
||||
// FilterResult holds the paginated conversations plus meta counts.
|
||||
// 1:1 Chatwoot ConversationFinder#perform response shape:
|
||||
// {conversations: [...], count: {mine_count, assigned_count, unassigned_count, all_count}}
|
||||
@@ -735,11 +744,16 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build base query scoped to account
|
||||
var conversations []model.Conversation
|
||||
var total int64
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = advancedQuery
|
||||
}
|
||||
|
||||
// 1. Filter by status
|
||||
if params.Status != "" {
|
||||
@@ -829,10 +843,6 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
|
||||
Where("contact_inboxes.source_id = ?", params.SourceID)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 9. Sort order — Chatwoot ConversationFinder::SORT_OPTIONS
|
||||
orderClause := "last_activity_at DESC" // default: last_activity_at_desc
|
||||
switch params.SortBy {
|
||||
@@ -854,21 +864,23 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
|
||||
orderClause = "waiting_since DESC"
|
||||
}
|
||||
|
||||
countQuery := query.Session(&gorm.Session{})
|
||||
var mineCount, unassignedCount, allCount int64
|
||||
if err := countQuery.Session(&gorm.Session{}).Where("assignee_id = ?", userID).Count(&mineCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := countQuery.Session(&gorm.Session{}).Where("assignee_id IS NULL").Count(&unassignedCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := countQuery.Session(&gorm.Session{}).Count(&allCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := query.Offset(offset).Limit(limit).Order(orderClause).Find(&conversations).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1:1 Chatwoot: set_count_for_all_conversations
|
||||
// Build count queries from the base filtered query (before pagination)
|
||||
var mineCount, unassignedCount, allCount int64
|
||||
s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).
|
||||
Where("account_id = ? AND assignee_id = ?", accountID, userID).Count(&mineCount)
|
||||
s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).
|
||||
Where("account_id = ? AND assignee_id IS NULL", accountID).Count(&unassignedCount)
|
||||
s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).
|
||||
Where("account_id = ?", accountID).Count(&allCount)
|
||||
|
||||
return &FilterResult{
|
||||
Conversations: conversations,
|
||||
Count: FilterCountMeta{
|
||||
@@ -880,6 +892,66 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ConversationService) applyConversationFilterPayload(query *gorm.DB, payload []ConversationFilterCondition) (*gorm.DB, error) {
|
||||
for _, condition := range payload {
|
||||
attribute := strings.TrimSpace(condition.AttributeKey)
|
||||
operator := strings.TrimSpace(condition.FilterOperator)
|
||||
values := conversationFilterStringValues(condition.Values)
|
||||
|
||||
column, allowedOperators, err := conversationFilterColumn(attribute)
|
||||
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)
|
||||
}
|
||||
|
||||
switch operator {
|
||||
case "equal_to":
|
||||
query = query.Where(column+" IN ?", values)
|
||||
case "not_equal_to":
|
||||
query = query.Where(column+" NOT IN ?", values)
|
||||
}
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func conversationFilterColumn(attribute string) (string, []string, error) {
|
||||
switch attribute {
|
||||
case "status":
|
||||
return "status", []string{"equal_to", "not_equal_to"}, nil
|
||||
case "priority":
|
||||
return "priority", []string{"equal_to", "not_equal_to"}, nil
|
||||
case "display_id":
|
||||
return "display_id", []string{"equal_to", "not_equal_to"}, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute)
|
||||
}
|
||||
}
|
||||
|
||||
func conversationFilterOperatorAllowed(operator string, allowed []string) bool {
|
||||
for _, value := range allowed {
|
||||
if operator == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func conversationFilterStringValues(values []any) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text != "" {
|
||||
result = append(result, text)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ListWithFinder retrieves conversations for the Chatwoot index/search finder contract.
|
||||
// Reference: Chatwoot ConversationFinder#perform.
|
||||
func (s *ConversationService) ListWithFinder(ctx context.Context, accountID, userID uint, params FilterParams, offset, limit int) (*FilterResult, error) {
|
||||
|
||||
Reference in New Issue
Block a user