feat(conversations): align standard filters

This commit is contained in:
2026-06-07 04:51:20 +08:00
parent f8d326ca2a
commit 576239b842
3 changed files with 245 additions and 10 deletions
+13 -9
View File
@@ -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.
@@ -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{}{
+96 -1
View File
@@ -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)
}