From f8d326ca2a1d4ac4fd9a97ff7d5bba56553d0bae Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 04:42:03 +0800 Subject: [PATCH] feat(conversations): align filter attributes --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 22 +- .../api/v1/conversation_handler_crud_test.go | 124 +++++++++++ internal/service/conversation_service.go | 199 +++++++++++++++--- 3 files changed, 310 insertions(+), 35 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index cd96e009..6ff107ca 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.65 conversation filter query-operator parity, prepared as `feat(conversations): align filter query operators`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align filter query operators`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Conversations::FilterService` frontend `query_operator` chain behavior and invalid query-operator error shape. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.66 conversation filter attribute parity, prepared as `feat(conversations): align filter attributes`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align filter attributes`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Conversations::FilterService` additional-attribute filters and conversation custom-attribute filter behavior. 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 filter payload conditions now honor Chatwoot `query_operator` chains with SQL `AND`/`OR` precedence and raw invalid-query-operator errors, while retaining P3.64 standard status/priority/display-id filtering and filtered counts. 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. +- Worktree status at this implementation checkpoint: conversation filter payload conditions now support Chatwoot conversation `additional_attributes` keys (`browser_language`, `conversation_language`, `referer`, `mail_subject`) and account-scoped conversation custom attributes with null-inclusive `not_equal_to`, while retaining P3.65 query-operator chains, P3.64 standard status/priority/display-id filters, and filtered counts. 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 remaining conversation filter labels/dates/assignee/inbox/team/message payload 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. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -156,8 +156,9 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 0 | P3.65 conversation filter query-operator parity | Implemented for reused dashboard advanced conversation filters: `{ payload: [...] }` conditions now honor `query_operator` chains (`AND`/`OR`) using the same SQL precedence as Chatwoot's concatenated filter query, and invalid query operators return raw `422 { error }`. | Keep in Review; reopen from B12 filter smoke or fresh reference evidence for 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.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 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.66 conversation filter attribute parity | Implemented for reused dashboard advanced conversation filters: `{ payload: [...] }` now supports Chatwoot conversation additional attributes from `filter_keys.yml` and account-scoped conversation custom attributes, including text/list/link case handling and `not_equal_to` matching rows where the custom attribute is absent. | Keep in Review; reopen from B12 filter smoke or fresh reference evidence for labels, dates, assignee/inbox/team/message filters, contact custom attributes inside conversation 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.65 conversation filter query-operator parity | Implemented for reused dashboard advanced conversation filters: `{ payload: [...] }` conditions now honor `query_operator` chains (`AND`/`OR`) using the same SQL precedence as Chatwoot's concatenated filter query, and invalid query operators return raw `422 { error }`. | Keep in Review; reopen from B12 filter smoke or fresh reference evidence for labels, dates, assignee/inbox/team/message filters, contact custom attributes inside conversation 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.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 labels, dates, assignee/inbox/team/message filters, contact custom attributes inside conversation 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. | @@ -228,6 +229,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.66 conversation filter attribute parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler_crud_test.go` | `reference/chatwoot/lib/filters/filter_keys.yml`, `reference/chatwoot/app/helpers/filters/filter_helper.rb#handle_additional_attributes`, `reference/chatwoot/app/services/filters/custom_attribute_filter_helper.rb`, `reference/chatwoot/app/services/filter_service.rb#filter_operation`, `reference/chatwoot/app/services/conversations/filter_service.rb` | Conversation filter payload now supports additional-attribute keys `browser_language`, `conversation_language`, `referer`, and `mail_subject` with Chatwoot operator sets, dialect-aware JSON extraction, case-insensitive contains checks, and account-scoped conversation custom attribute definitions. Custom `not_equal_to` includes conversations where the custom attribute key is absent, matching Chatwoot's appended `IS NULL` behavior. | Review by `feat(conversations): align filter attributes`; focused filter handler tests assert additional-attribute equality, additional-attribute contains, and custom-attribute `not_equal_to` null inclusion; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.65 conversation filter query-operator parity | `internal/service/conversation_service.go`, conversation filter handler tests | `reference/chatwoot/app/services/filter_service.rb#query_builder`, `reference/chatwoot/app/helpers/filters/filter_helper.rb#validate_single_condition`, `reference/chatwoot/spec/services/conversations/filter_service_frontend_alignment_spec.rb` | Conversation filter payload conditions now append `query_operator` into one SQL predicate chain, matching Chatwoot's `A AND B OR C` / `A OR B AND C` frontend alignment behavior and returning raw `422 { error: 'Query operator must be either "AND" or "OR".' }` for invalid operators. | Review by `feat(conversations): align filter query operators`; focused filter handler tests assert OR/AND precedence and invalid query-operator error; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | 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. | @@ -318,8 +320,9 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | -| `feat(conversations): align filter query operators` | Advances P3.65 with Chatwoot conversation filter frontend alignment. Payload conditions now form a single SQL predicate chain with `AND`/`OR` operators and database precedence, so `A OR B AND C` and `A AND B OR C` behave like the reference `FilterService#query_builder`; invalid query operators return raw `422 { error }`. | `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.65 to Review; continue Phase 2/3 drift audit for full filter custom/additional-attribute depth, Phase 6 placeholder audit, or B12 live smoke. | -| `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 custom-attribute depth, Phase 6 placeholder audit, or B12 live smoke. | +| `feat(conversations): align filter attributes` | Advances P3.66 with Chatwoot conversation filter additional/custom attribute parity. Payload filters now cover `browser_language`, `conversation_language`, `referer`, and `mail_subject` through JSON extraction and Chatwoot operator sets, and custom conversation attributes are resolved through account-scoped definitions with text/list/link lowercasing plus null-inclusive `not_equal_to`. | `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.66 to Review; continue Phase 2/3 drift audit for labels/dates/assignee/inbox/team/message filter payload depth, contact custom attributes inside conversation filters, Phase 6 placeholder audit, or B12 live smoke. | +| `feat(conversations): align filter query operators` | Advances P3.65 with Chatwoot conversation filter frontend alignment. Payload conditions now form a single SQL predicate chain with `AND`/`OR` operators and database precedence, so `A OR B AND C` and `A AND B OR C` behave like the reference `FilterService#query_builder`; invalid query operators return raw `422 { error }`. | `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.65 to Review; continue Phase 2/3 drift audit for labels/dates/assignee/inbox/team/message filter payload depth, Phase 6 placeholder audit, or B12 live smoke. | +| `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 labels/dates/assignee/inbox/team/message filter payload 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. | @@ -2641,3 +2644,4 @@ Verification milestone gates: - 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. - 2026-06-07: P3.65 conversation filter query-operator checkpoint prepared as `feat(conversations): align filter query operators`; audited Chatwoot `FilterService#query_builder`, `Filters::FilterHelper#validate_single_condition`, and frontend alignment specs. GoChat filter payload chains now honor `AND`/`OR` query operators with SQL precedence and return raw invalid-query-operator errors. Focused filter handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.66 conversation filter attribute checkpoint prepared as `feat(conversations): align filter attributes`; audited Chatwoot `filter_keys.yml`, `Filters::FilterHelper#handle_additional_attributes`, `Filters::CustomAttributeFilterHelper`, `FilterService#filter_operation`, and `Conversations::FilterService`. GoChat payload filters now support conversation additional attributes (`browser_language`, `conversation_language`, `referer`, `mail_subject`) and account-scoped conversation custom attributes, including case-normalized text/list/link comparisons and `not_equal_to` matching absent custom-attribute keys. 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_crud_test.go b/internal/handler/api/v1/conversation_handler_crud_test.go index 59773e6f..6d766f40 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -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{}{ diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 8af193b8..b69b4690 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -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 {