From 576239b8422317750e503bd616501873aa12cf3d Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 04:51:20 +0800 Subject: [PATCH] feat(conversations): align standard filters --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 22 +-- .../api/v1/conversation_handler_crud_test.go | 136 ++++++++++++++++++ internal/service/conversation_service.go | 97 ++++++++++++- 3 files changed, 245 insertions(+), 10 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 6ff107ca..6bd5a5d2 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.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. +- Current tracking checkpoint: 2026-06-07 P3.67 conversation filter standard-operator parity, prepared as `feat(conversations): align standard filters`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align standard filters`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Conversations::FilterService` standard labels/presence/date operator 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 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. +- Worktree status at this implementation checkpoint: conversation filter payload conditions now support the remaining Chatwoot standard conversation filter operators for `assignee_id`, `inbox_id`, `team_id`, `campaign_id`, `labels`, `display_id`, `created_at`, and `last_activity_at`, while retaining P3.66 additional/custom attributes, P3.65 query-operator chains, P3.64 status/priority/display-id basics, 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 message/contact custom-attribute 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,9 +156,10 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 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.67 conversation filter standard-operator parity | Implemented for reused dashboard advanced conversation filters: `{ payload: [...] }` now covers Chatwoot standard conversation operators for labels, presence checks, display-id contains, and date comparisons/days-before on `created_at` and `last_activity_at`. | Keep in Review; reopen from B12 filter smoke or fresh reference evidence for 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.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 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 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 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. | @@ -229,6 +230,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.67 conversation filter standard-operator 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/services/filter_service.rb#filter_operation`, `reference/chatwoot/app/services/filter_service.rb#tag_filter_query`, `reference/chatwoot/app/services/filter_service.rb#lt_gt_filter_query`, `reference/chatwoot/app/services/conversations/filter_service.rb` | Conversation filter payload now supports Chatwoot standard filter operators beyond the P3.64 basics: `assignee_id`, `inbox_id`, `team_id`, and `campaign_id` support equality plus present/absent checks; `display_id` supports contains/does-not-contain through text casting; `labels` uses account tag joins with equal/not-equal/present/absent semantics; `created_at` and `last_activity_at` support greater-than, less-than, and days-before. | Review by `feat(conversations): align standard filters`; focused filter handler tests assert presence filters, label joins, display-id contains with date filters, last-activity days-before, full `go test ./...`, and `git diff --check`. No route artifacts change. | | 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. | @@ -320,7 +322,8 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | -| `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 standard filters` | Advances P3.67 with Chatwoot conversation filter standard-operator parity. Payload filters now cover presence operators for assignee/inbox/team/campaign fields, display-id contains/does-not-contain through text casts, account tag joins for labels, and date/days-before comparisons for `created_at` plus Unix-second `last_activity_at`. | `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.67 to Review; continue Phase 2/3 drift audit for message filters, contact custom attributes inside conversation filters, 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 message filters, 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. | @@ -2645,3 +2648,4 @@ Verification milestone gates: - 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. +- 2026-06-07: P3.67 conversation filter standard-operator checkpoint prepared as `feat(conversations): align standard filters`; audited Chatwoot `filter_keys.yml`, `FilterService#tag_filter_query`, `#filter_operation`, `#lt_gt_filter_query`, and `Conversations::FilterService`. GoChat payload filters now support presence operators for assignee/inbox/team/campaign fields, label equal/not-equal/present/absent through account tag joins, `display_id` contains/does-not-contain through text casts, and date/days-before comparisons for `created_at` and Unix-second `last_activity_at`. 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 6d766f40..064a6409 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -1217,6 +1217,142 @@ func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadCustomAttributeNot assert.ElementsMatch(s.T(), []float64{float64(silverID), float64(nilID)}, ids) } +func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadStandardPresenceFilters() { + agentID := uint(7001) + teamID := uint(7002) + campaignID := uint(7003) + s.testConv.AssigneeID = &agentID + s.testConv.TeamID = &teamID + s.testConv.CampaignID = &campaignID + s.Require().NoError(s.db.Save(s.testConv).Error) + + unassigned := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(unassigned).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "payload": []map[string]interface{}{ + {"attribute_key": "assignee_id", "filter_operator": "is_present", "values": []string{}, "query_operator": "AND"}, + {"attribute_key": "team_id", "filter_operator": "equal_to", "values": []string{"7002"}, "query_operator": "AND"}, + {"attribute_key": "campaign_id", "filter_operator": "is_present", "values": []string{}}, + }, + }) + 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_ChatwootPayloadLabels() { + vip := &model.Tag{AccountID: s.testAccount.ID, Name: "vip", Color: "#1f93ff"} + s.Require().NoError(s.db.Create(vip).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.testAccount.ID, ConversationID: s.testConv.ID, TagID: vip.ID}).Error) + + plainID := uint(33303) + plain := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, DisplayID: &plainID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(plain).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "payload": []map[string]interface{}{ + {"attribute_key": "labels", "filter_operator": "equal_to", "values": []string{"vip"}}, + }, + }) + 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_ChatwootPayloadDisplayIDContainsAndDate() { + primaryID := uint(45678) + s.testConv.DisplayID = &primaryID + s.Require().NoError(s.db.Save(s.testConv).Error) + s.Require().NoError(s.db.Model(&model.Conversation{}).Where("id = ?", s.testConv.ID).Update("created_at", time.Date(2024, 1, 20, 10, 0, 0, 0, time.UTC)).Error) + + olderID := uint(12345) + older := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, DisplayID: &olderID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(older).Error) + s.Require().NoError(s.db.Model(&model.Conversation{}).Where("id = ?", older.ID).Update("created_at", time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "payload": []map[string]interface{}{ + {"attribute_key": "display_id", "filter_operator": "contains", "values": []string{"567"}, "query_operator": "AND"}, + {"attribute_key": "created_at", "filter_operator": "is_greater_than", "values": []string{"2024-01-10"}}, + }, + }) + 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(primaryID), resp.Payload[0]["id"]) +} + +func (s *ConversationCrudTestSuite) TestFilter_ChatwootPayloadLastActivityAtDaysBefore() { + recentActivity := time.Now().UTC().AddDate(0, 0, -1).Unix() + oldActivity := time.Now().UTC().AddDate(0, 0, -12).Unix() + s.testConv.LastActivityAt = &oldActivity + s.Require().NoError(s.db.Save(s.testConv).Error) + + recent := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "open", LastActivityAt: &recentActivity, ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(recent).Error) + + body, _ := json.Marshal(map[string]interface{}{ + "payload": []map[string]interface{}{ + {"attribute_key": "last_activity_at", "filter_operator": "days_before", "values": []string{"7"}}, + }, + }) + 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_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 b69b4690..fbc34fc9 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "time" @@ -931,6 +932,12 @@ func (s *ConversationService) conversationFilterClause(ctx context.Context, quer attribute := strings.TrimSpace(condition.AttributeKey) operator := strings.TrimSpace(condition.FilterOperator) values := conversationFilterStringValues(condition.Values) + if attribute == "labels" { + return conversationFilterLabelsClause(accountID, operator, values) + } + if attribute == "created_at" || attribute == "last_activity_at" { + return conversationFilterDateClause(attribute, operator, values) + } column, allowedOperators, err := conversationFilterColumn(attribute) if err == nil { @@ -965,13 +972,95 @@ func conversationFilterColumn(attribute string) (string, []string, error) { return "conversations.status", []string{"equal_to", "not_equal_to"}, nil case "priority": return "conversations.priority", []string{"equal_to", "not_equal_to"}, nil + case "assignee_id": + return "conversations.assignee_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil + case "inbox_id": + return "conversations.inbox_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil + case "team_id": + return "conversations.team_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil case "display_id": - return "conversations.display_id", []string{"equal_to", "not_equal_to"}, nil + return "CAST(conversations.display_id AS TEXT)", []string{"equal_to", "not_equal_to", "contains", "does_not_contain"}, nil + case "campaign_id": + return "conversations.campaign_id", []string{"equal_to", "not_equal_to", "is_present", "is_not_present"}, nil default: return "", nil, fmt.Errorf("Invalid attribute key - [%s]", attribute) } } +func conversationFilterLabelsClause(accountID uint, operator string, values []string) (string, []any, error) { + allowedOperators := []string{"equal_to", "not_equal_to", "is_present", "is_not_present"} + if !conversationFilterOperatorAllowed(operator, allowedOperators) { + return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ",")) + } + if operator != "is_present" && operator != "is_not_present" && len(values) == 0 { + return "", nil, fmt.Errorf("Invalid value for labels") + } + + base := "SELECT 1 FROM conversation_labels JOIN tags ON tags.id = conversation_labels.tag_id WHERE conversation_labels.conversation_id = conversations.id AND conversation_labels.account_id = ? AND tags.account_id = ?" + args := []any{accountID, accountID} + switch operator { + case "equal_to": + args = append(args, values) + return "EXISTS (" + base + " AND tags.name IN ?)", args, nil + case "not_equal_to": + args = append(args, values) + return "NOT EXISTS (" + base + " AND tags.name IN ?)", args, nil + case "is_present": + return "EXISTS (" + base + ")", args, nil + case "is_not_present": + return "NOT EXISTS (" + base + ")", args, nil + default: + return "", nil, fmt.Errorf("Invalid operator. The allowed operators for labels are [%s].", strings.Join(allowedOperators, ",")) + } +} + +func conversationFilterDateClause(attribute, operator string, values []string) (string, []any, error) { + allowedOperators := []string{"is_greater_than", "is_less_than", "days_before"} + 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) + } + + value, err := conversationFilterDateValue(attribute, operator, values[0]) + if err != nil { + return "", nil, fmt.Errorf("Invalid value for %s", attribute) + } + comparison := ">" + if operator == "is_less_than" || operator == "days_before" { + comparison = "<" + } + if attribute == "last_activity_at" { + return "conversations.last_activity_at " + comparison + " ?", []any{value.Unix()}, nil + } + return "conversations.created_at " + comparison + " ?", []any{value}, nil +} + +func conversationFilterDateValue(attribute, operator, value string) (time.Time, error) { + if operator == "days_before" { + days, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return time.Time{}, err + } + today := time.Now().UTC().Truncate(24 * time.Hour) + return today.AddDate(0, 0, -days), nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + if parsed, err := time.Parse("2006-01-02", value); err == nil { + return parsed, nil + } + if attribute == "last_activity_at" { + seconds, err := strconv.ParseInt(value, 10, 64) + if err == nil { + return time.Unix(seconds, 0).UTC(), nil + } + } + return time.Time{}, fmt.Errorf("invalid date") +} + type conversationAdditionalFilter struct { allowedOperators []string } @@ -1016,6 +1105,12 @@ func conversationFilterBuildClause(attribute, expression string, expressionArgs if !conversationFilterOperatorAllowed(operator, allowedOperators) { return "", nil, fmt.Errorf("Invalid operator. The allowed operators for %s are [%s].", attribute, strings.Join(allowedOperators, ",")) } + if operator == "is_present" { + return expression + " IS NOT NULL", append([]any{}, expressionArgs...), nil + } + if operator == "is_not_present" { + return expression + " IS NULL", append([]any{}, expressionArgs...), nil + } if len(values) == 0 { return "", nil, fmt.Errorf("Invalid value for %s", attribute) }