diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index ff1f183d..6b90b7ee 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.63 conversation search message-order parity, prepared as `feat(conversations): align search message order`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align search message order`. -- Latest documentation/tooling checkpoint: this tracker update records that Chatwoot search `_conversation` renders `conversation.messages` using `Message` default `created_at ASC` ordering. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.64 conversation filter payload parity, prepared as `feat(conversations): align filter payload basics`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align filter payload basics`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Conversations::FilterService` frontend payload basics for `status`, `priority`, and `display_id`, including raw `422 { error }` invalid-filter responses. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: conversation search result messages now follow Chatwoot `Message` default ordering by `created_at ASC` with `id ASC` as a deterministic tie-breaker, while retaining P3.62 trimmed payload fields, P3.61 sender-name, and P3.60 numeric message-type parity. P3.59 search finder payload parity, P3.58 index finder parity, P3.57/P3.56/P3.55 conversation meta parity, P3.54 unread-count parity, P5.8b contact import DataImportJob parity, P3.53 first-response distribution parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: conversation filter now accepts Chatwoot frontend `{ payload: [...] }` conditions for standard status/priority/display-id filters, returns raw `422 { error }` for invalid attributes/operators, and computes filter counts from the filtered relation. P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. @@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.64 conversation filter payload basics | Implemented for reused dashboard advanced conversation filters: GoChat now accepts Chatwoot `{ payload: [...] }` bodies for `status`, `priority`, and `display_id` with `equal_to`/`not_equal_to`, computes meta counts from the filtered relation, and returns raw `422 { error }` for invalid attributes/operators. | Keep in Review; reopen from B12 filter smoke or fresh reference evidence for query-operator chains, custom/additional attributes, labels, dates, assignee/inbox/team/message filters, or full `Conversations::PermissionFilterService` depth. | Focused conversation filter handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.63 conversation search message-order parity | Implemented for reused dashboard conversation search results: GoChat now follows Chatwoot `Message` `default_scope { order(created_at: :asc) }` when serializing `conversation.messages` inside search payload items. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for sender polymorphic types beyond Contact/User or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.62 conversation search payload-field parity | Implemented for reused dashboard conversation search results: GoChat now follows `conversations/search.json.jbuilder` plus the search `_conversation` partial by omitting the normal conversation `meta` object from each search payload item. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for sender polymorphic types beyond Contact/User or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.61 conversation search sender-name parity | Implemented for reused dashboard conversation search results: GoChat now follows inspected `api/v1/models/_conversation.json.jbuilder` by emitting `sender_name` from `message.sender.name` for persisted contact/user senders in search result messages. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for message ordering, sender polymorphic types beyond Contact/User, or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -226,6 +227,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | +| P3.64 conversation filter payload basics | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, conversation filter handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#filter`, `reference/chatwoot/app/services/conversations/filter_service.rb`, `reference/chatwoot/app/services/filter_service.rb`, `reference/chatwoot/lib/filters/filter_keys.yml`, `reference/chatwoot/spec/controllers/api/v1/accounts/conversations_controller_spec.rb` | Conversation filter now accepts the reused dashboard's `{ payload: [...] }` body for `status`, `priority`, and `display_id` with `equal_to`/`not_equal_to`, computes `mine_count`, `assigned_count`, `unassigned_count`, and `all_count` from the filtered relation, and returns raw Chatwoot-style `422 { error }` for invalid attributes/operators. | Review by `feat(conversations): align filter payload basics`; focused filter handler tests assert payload status filtering, filtered counts, raw invalid-attribute/operator errors, full `go test ./...`, and `git diff --check`. No route artifacts change. | | P3.63 conversation search message-order parity | `internal/handler/api/v1/conversation_serializer.go`, conversation search handler tests | `reference/chatwoot/app/views/api/v1/models/_conversation.json.jbuilder`, `reference/chatwoot/app/models/message.rb` | Conversation search result messages now serialize in Chatwoot association order by `created_at ASC`, matching `Message` default scope when the search partial iterates `conversation.messages`. GoChat keeps `id ASC` only as a deterministic tie-breaker for same-timestamp rows. | Review by `feat(conversations): align search message order`; focused search handler tests assert created-at ordering across messages inserted out of chronological order; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.62 conversation search payload-field parity | `internal/handler/api/v1/conversation_serializer.go`, conversation search handler tests | `reference/chatwoot/app/views/api/v1/accounts/conversations/search.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_conversation.json.jbuilder` | Conversation search payload items now omit the normal list/show `meta` object, matching the dedicated search partial fields: `id`, `uuid`, `created_at`, `contact`, `inbox`, `messages`, and `account_id`. | Review by `feat(conversations): trim search payload meta`; focused search handler tests assert the root `meta` still exists but each payload item has no `meta`; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.61 conversation search sender-name parity | `internal/handler/api/v1/conversation_serializer.go`, conversation search handler tests | `reference/chatwoot/app/views/api/v1/models/_conversation.json.jbuilder`, `reference/chatwoot/app/views/api/v1/accounts/conversations/search.json.jbuilder` | Conversation search result messages now serialize `sender_name` when the persisted sender can be resolved, matching `json.sender_name message.sender.name if message.sender` for contact and user senders. | Review by `feat(conversations): align search sender names`; focused search handler tests assert contact and user `sender_name`; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | @@ -314,6 +316,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(conversations): align filter payload basics` | Advances P3.64 with Chatwoot conversation filter payload basics. `POST /conversations/filter` now understands the frontend `{ payload: [...] }` filter format for standard `status`, `priority`, and `display_id` attributes, emits raw `422 { error }` for invalid attributes/operators, and makes filter meta counts come from the filtered relation instead of the whole account. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestFilter' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search\|Filter)\|ConversationCrudTestSuite/Test(Filter\|Search\|List)\|ConversationService' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.64 to Review; continue Phase 2/3 drift audit for full filter query-operator/custom-attribute depth, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align search message order` | Advances P3.63 with Chatwoot conversation search message-order parity. Search result messages now use `created_at ASC, id ASC`, matching the reference `_conversation` partial iterating `conversation.messages` under `Message` default `created_at ASC` scope while keeping deterministic ties. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestSearch' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search)\|ConversationCrudTestSuite/Test(Search\|List)' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.63 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): trim search payload meta` | Advances P3.62 with Chatwoot conversation search payload-field parity. Search result payload items now use only the dedicated search `_conversation` partial fields and no longer include the normal list/show `meta` object, while preserving the root search `meta` counts and P3.61/P3.60 message fields. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestSearch' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search)\|ConversationCrudTestSuite/Test(Search\|List)' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.62 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align search sender names` | Advances P3.61 with Chatwoot conversation search model serializer parity. Search result messages now resolve persisted contact/user senders and emit `sender_name` using the same `message.sender.name if message.sender` behavior as the reference `_conversation` Jbuilder, while preserving P3.60 numeric `message_type` and P3.59 root search payload behavior. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestSearch' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search)\|ConversationCrudTestSuite/Test(Search\|List)' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.61 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2633,3 +2636,4 @@ Verification milestone gates: - 2026-06-07: P3.61 conversation search sender-name checkpoint prepared as `feat(conversations): align search sender names`; audited Chatwoot search `_conversation` model serializer and its `json.sender_name message.sender.name if message.sender` field. GoChat conversation search result messages now resolve persisted contact/user senders and emit `sender_name` alongside the existing numeric `message_type` payload. Focused search handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.62 conversation search payload-field checkpoint prepared as `feat(conversations): trim search payload meta`; audited Chatwoot `conversations/search.json.jbuilder` and the dedicated search `_conversation` partial. GoChat search payload items now omit the normal conversation `meta` object and keep only the search partial fields while preserving root search `meta` counts. Focused search handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.63 conversation search message-order checkpoint prepared as `feat(conversations): align search message order`; audited Chatwoot search `_conversation` partial and `Message` default scope. GoChat search result messages now order by `created_at ASC, id ASC` instead of `id ASC`, matching `conversation.messages` chronological rendering while keeping deterministic ties. Focused search handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.64 conversation filter payload basics checkpoint prepared as `feat(conversations): align filter payload basics`; audited Chatwoot `ConversationsController#filter`, `Conversations::FilterService`, base `FilterService`, filter key config, and controller specs. GoChat `POST /conversations/filter` now accepts frontend `{ payload: [...] }` filters for `status`, `priority`, and `display_id`, returns raw `422 { error }` for invalid attributes/operators, and computes filter meta counts from the filtered relation. Focused filter handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index f9c95e4a..d436cdbe 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -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 } diff --git a/internal/handler/api/v1/conversation_handler_crud_test.go b/internal/handler/api/v1/conversation_handler_crud_test.go index d66336ac..df4059d1 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -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", diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 0f589d1a..09bfd4fd 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -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) {