feat(conversations): align index finder filters
This commit is contained in:
@@ -49,11 +49,11 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-07 P3.57 conversation meta query/status parity, prepared as `feat(conversations): align meta query status`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align meta query status`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `ConversationFinder#set_up` status-skip behavior when `q` is present. No route artifacts change.
|
||||
- Current tracking checkpoint: 2026-06-07 P3.58 conversation index finder parity, prepared as `feat(conversations): align index finder filters`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align index finder filters`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records reused `ConversationApi.get` query params through Chatwoot `ConversationsController#index` and `ConversationFinder#perform`. 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 meta now also mirrors Chatwoot's `filter_by_status unless params[:q]` rule, so message-search sidebar counts include matching open/resolved/pending/snoozed conversations even when `status` is supplied. P3.56 query/source meta filters, P3.55 meta payload/filter parity, P3.54 unread-count parity, P5.8b contact import DataImportJob parity, P3.53 first-response distribution parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- Worktree status at this implementation checkpoint: conversation index now routes reused frontend `ConversationApi.get` query params through a ConversationFinder-style path, defaulting to open conversations, applying status/inbox/team/labels/conversation_type/source/query filters before counts, applying `assignee_type` only to the returned payload, and preserving raw `{ data: { meta, payload } }`. P3.57/P3.56/P3.55 conversation meta parity, P3.54 unread-count parity, P5.8b contact import DataImportJob parity, P3.53 first-response distribution parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
|
||||
- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full.
|
||||
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
|
||||
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
|
||||
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0 | P3.58 conversation index finder parity | Implemented for reused dashboard conversation list calls: GET `/conversations` now follows inspected Chatwoot `ConversationsController#index` and `ConversationFinder#perform` for the frontend query params, including default-open status, status skip while `q` is present, pre-assignee meta counts, post-count `assignee_type` payload filtering, labels/source/query filters, and finder sort/pagination behavior. | Keep in Review; reopen from B12 conversation list smoke or fresh reference evidence for full `Conversations::PermissionFilterService` custom-role depth, PostgreSQL `ILIKE` case-folding drift, duplicate message join count drift, or custom-filter POST `/conversations/filter` behavior. | Focused repository/list handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 0 | P3.57 conversation meta query/status parity | Implemented for reused conversation sidebar message search counts: GoChat now follows Chatwoot `ConversationFinder#set_up` by skipping status filtering whenever `q` is present, including explicit `status=open`, so query counts span all statuses before the raw `{ meta }` payload is returned. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for PostgreSQL `ILIKE` case-folding drift, duplicate message join count drift, or full conversation list `perform` query/status drift beyond meta. | Focused repository/handler meta query/status tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 0 | P3.56 conversation meta query/source parity | Implemented for reused conversation sidebar search/source counts: GoChat now follows inspected Chatwoot `ConversationFinder#filter_by_query` and `#filter_by_source_id` inside meta-only counts, joining public incoming/outgoing messages for `q` and contact inboxes for `source_id` before returning raw `{ meta }`. | Keep in Review; reopen from B12 conversation sidebar smoke or fresh reference evidence for PostgreSQL `ILIKE` case-folding drift, duplicate message join count drift, or deeper custom-role permission filtering. | Focused repository/handler meta query/source tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 0 | P3.55 conversation meta payload/filter parity | Implemented for reused conversation sidebar counts: GoChat now follows inspected Chatwoot `Api::V1::Accounts::ConversationsController#meta`, `ConversationFinder#perform_meta_only`, and `meta.json.jbuilder` by returning raw `{ meta }`, defaulting counts to open conversations, preserving meta-only `assignee_type` behavior, applying status/inbox/team/labels/conversation_type filters, and enforcing current-user inbox visibility for non-admins. | Keep in Review; reopen from B12 conversation sidebar smoke or fresh reference evidence for deeper custom-role permission filtering, `source_id/q` meta edge cases, or ActsAsTaggableOn label storage drift. | Focused repository/service/handler meta tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
@@ -220,6 +221,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.58 conversation index finder parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, `internal/repository/conversation_repo.go`, conversation CRUD/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#index`, `reference/chatwoot/app/finders/conversation_finder.rb`, `reference/chatwoot/app/views/api/v1/accounts/conversations/index.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js` | GET `/api/v1/accounts/:account_id/conversations` now uses a ConversationFinder-style path for reused `ConversationApi.get` params: default status is open, `q` skips status filtering, counts are computed before `assignee_type`, payload is filtered by `assignee_type` after counts, labels/source/query filters share the meta base, and finder sort/pagination behavior replaces the old status-only list path. | Review by `feat(conversations): align index finder filters`; focused repository/list handler tests cover default-open, query/status skip, labels, pre-assignee counts, and post-count payload filtering; full `go test ./...` and `git diff --check` must pass. No route artifacts change. |
|
||||
| P3.57 conversation meta query/status parity | `internal/repository/conversation_repo.go`, `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, conversation repository/handler tests | `reference/chatwoot/app/finders/conversation_finder.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#meta`, `reference/chatwoot/app/views/api/v1/accounts/conversations/meta.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js` | Conversation meta now mirrors `ConversationFinder#set_up`: `filter_by_status` is skipped whenever `params[:q]` is present, so meta-only message-search counts include matching conversations across all statuses while preserving `q` incoming/outgoing message filtering and `source_id` filtering from P3.56. | Review by `feat(conversations): align meta query status`; focused repository/handler tests cover query counts including resolved/pending conversations despite explicit `status=open`; full `go test ./...` and `git diff --check` must pass. No route artifacts change. |
|
||||
| P3.56 conversation meta query/source parity | `internal/repository/conversation_repo.go`, `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, conversation repository/handler tests | `reference/chatwoot/app/finders/conversation_finder.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#meta`, `reference/chatwoot/app/views/api/v1/accounts/conversations/meta.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js` | Conversation meta now applies the remaining finder filters before computing count payloads: `q` joins incoming/outgoing public messages and filters content, while `source_id` joins contact inboxes and filters `contact_inboxes.source_id`; activity/template messages do not satisfy query matches, and the raw `{ meta }` response from P3.55 is preserved. | Review by `feat(conversations): filter meta query source`; focused repository/handler tests cover message query, source filtering, and activity-message exclusion; full `go test ./...` and `git diff --check` must pass. No route artifacts change. |
|
||||
| P3.39 profile MFA route/payload parity | `internal/auth/mfa.go`, `internal/handler/api/v1/mfa_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, profile MFA handler/router tests | `reference/chatwoot/config/routes.rb:421-435`, `reference/chatwoot/app/controllers/api/v1/profiles_controller.rb`, `reference/chatwoot/app/controllers/api/v1/profile/mfa_controller.rb`, `reference/chatwoot/app/views/api/v1/profile/mfa/*.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/mfa.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue` | Done. Profile MFA now matches the reused dashboard route and payload family: no-trailing and trailing profile MFA routes are registered; route parity tracks profile show/update/action plus MFA routes; status/create/verify/destroy/backup-code responses are raw Chatwoot shapes; setup secrets are persisted before verification; frontend `otp_code` enables MFA and returns backup codes; backup-code hashes are stored in user custom attributes; disable validates password plus OTP/backup-code. | Review by `feat(profile): align mfa payloads`; focused MFA handler tests cover raw status, setup, verify, backup-code regeneration, and disable payloads; route artifacts cover profile routes; full `go test ./...` and `git diff --check` must pass. |
|
||||
@@ -302,6 +304,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(conversations): align index finder filters` | Advances P3.58 with Chatwoot conversation index finder parity. Reused frontend GET `/conversations` query params now flow through a ConversationFinder-style service/repository path: default-open status, status skip while `q` is present, labels/source/query filters, pre-assignee meta counts, post-count `assignee_type` payload filtering, and finder sort/pagination replace the previous status-only list behavior. | `go test ./internal/repository ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|ConversationCrudTestSuite/TestList' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'Conversation.*(Meta\|List)\|ConversationRepo\|ConversationService\|ConversationCrudTestSuite' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.58 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(conversations): align meta query status` | Advances P3.57 with Chatwoot conversation meta query/status parity. `GET /conversations/meta` now follows `ConversationFinder#set_up` by skipping status filtering whenever `q` is present, even if the frontend also sends `status`, so sidebar message-search counts include matching conversations across all statuses before returning raw `{ meta }`. | `go test ./internal/repository ./internal/handler/api/v1 -run 'ConversationRepo_GetMeta\|ConversationHandlerTestSuite/TestMeta' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'Conversation.*Meta\|ConversationRepo\|ConversationService\|ConversationHandlerTestSuite' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.57 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(conversations): filter meta query source` | Advances P3.56 with remaining Chatwoot conversation meta filter parity. `GET /conversations/meta` now applies `ConversationFinder#filter_by_query` and `#filter_by_source_id` before computing raw `{ meta }` counts, joining incoming/outgoing messages for `q` and account contact inboxes for `source_id` so sidebar/search counts follow the same filtered base as the conversation finder. | `go test ./internal/repository ./internal/handler/api/v1 -run 'ConversationRepo_GetMeta\|ConversationHandlerTestSuite/TestMeta' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'Conversation.*Meta\|ConversationRepo\|ConversationService\|ConversationHandlerTestSuite' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.56 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(conversations): align meta counts` | Advances P3.55 with Chatwoot conversation meta count parity. `GET /conversations/meta` now returns raw `{ meta }` instead of the local envelope/status-count shape, computes `mine_count`, `assigned_count`, `unassigned_count`, and `all_count` from the same filtered base as `ConversationFinder#perform_meta_only`, defaults status to open, applies status/inbox/team/labels/conversation_type filters, ignores `assignee_type` for meta-only counts, and scopes non-admin users to assigned inboxes. | `go test ./internal/repository -run 'ConversationRepo_GetMeta' -count=1`; `go test ./internal/service -run 'ConversationService_GetMeta' -count=1`; `go test ./internal/handler/api/v1 -run 'ConversationHandlerTestSuite/TestMeta' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'Conversation.*Meta\|ConversationRepo\|ConversationService\|ConversationHandlerTestSuite' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.55 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
@@ -2609,3 +2612,4 @@ Verification milestone gates:
|
||||
- 2026-06-07: P3.55 conversation meta checkpoint prepared as `feat(conversations): align meta counts`; audited Chatwoot `ConversationsController#meta`, `ConversationFinder#perform_meta_only`, `meta.json.jbuilder`, and reused dashboard conversation API/store. GoChat conversation meta now returns raw `{ meta }` with `mine_count`, `assigned_count`, `unassigned_count`, and `all_count`, defaults to open conversations, preserves meta-only assignee-type behavior, applies status/inbox/team/labels/conversation-type filters, and scopes non-admin agents by assigned inboxes. Focused repository/service/handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P3.56 conversation meta query/source checkpoint prepared as `feat(conversations): filter meta query source`; audited Chatwoot `ConversationFinder#filter_by_query` and `#filter_by_source_id` plus the reused conversation meta route. GoChat meta counts now join incoming/outgoing messages for `q`, ignore activity/template messages, join contact inboxes for `source_id`, and preserve raw `{ meta }` count payloads from P3.55. Focused repository/handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P3.57 conversation meta query/status checkpoint prepared as `feat(conversations): align meta query status`; audited Chatwoot `ConversationFinder#set_up`, specifically `filter_by_status unless params[:q]`, plus the reused conversation meta route. GoChat meta counts now skip status filtering whenever `q` is present, including explicit `status=open`, so message-search sidebar counts include matching conversations across all statuses while preserving raw `{ meta }`. Focused repository/handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P3.58 conversation index finder checkpoint prepared as `feat(conversations): align index finder filters`; audited Chatwoot `ConversationsController#index`, `ConversationFinder#perform`, `index.json.jbuilder`, and reused dashboard `ConversationApi.get`. GoChat GET `/conversations` now uses the finder-style filtered base instead of the old status-only list path: default-open status, `q` status-skip, labels/source/query filters, pre-assignee meta counts, post-count `assignee_type` payload filtering, and finder sort/pagination are covered. Focused repository/list handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
|
||||
|
||||
@@ -61,30 +61,26 @@ func (h *ConversationHandler) List(c *gin.Context) {
|
||||
|
||||
p := pagination.Parse(c)
|
||||
|
||||
// Support status filter via query param
|
||||
status := c.Query("status")
|
||||
var conversations []model.Conversation
|
||||
var total int64
|
||||
|
||||
if status != "" {
|
||||
result, count, svcErr := h.conversationSvc.ListByStatus(c.Request.Context(), accountID, status, p.Offset, p.PerPage)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
conversations = result
|
||||
total = count
|
||||
} else {
|
||||
result, count, svcErr := h.conversationSvc.ListByAccount(c.Request.Context(), accountID, p.Offset, p.PerPage)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
conversations = result
|
||||
total = count
|
||||
var params service.FilterParams
|
||||
if err := c.ShouldBindQuery(¶ms); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, serializeConversationList(c.Request.Context(), h.conversationSvc.DB(), conversations, total))
|
||||
result, svcErr := h.conversationSvc.ListWithFinder(c.Request.Context(), accountID, getUserID(c), params, p.Offset, p.PerPage)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
payload := serializeConversationList(c.Request.Context(), h.conversationSvc.DB(), result.Conversations, result.Count.AllCount)
|
||||
payload.Data.Meta = chatwootConversationCounts{
|
||||
MineCount: result.Count.MineCount,
|
||||
AssignedCount: result.Count.AssignedCount,
|
||||
UnassignedCount: result.Count.UnassignedCount,
|
||||
AllCount: result.Count.AllCount,
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// Create creates a new conversation.
|
||||
|
||||
@@ -222,6 +222,9 @@ func (s *ConversationCrudTestSuite) TestList_Success() {
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestList_WithStatusFilter() {
|
||||
resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(resolved).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations?status=open", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
@@ -240,6 +243,81 @@ func (s *ConversationCrudTestSuite) TestList_WithStatusFilter() {
|
||||
assert.Equal(s.T(), int64(1), resp.Data.Meta.AllCount)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestList_DefaultsToOpenStatus() {
|
||||
resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(resolved).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Meta struct {
|
||||
AllCount int64 `json:"all_count"`
|
||||
} `json:"meta"`
|
||||
Payload []map[string]interface{} `json:"payload"`
|
||||
} `json:"data"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), int64(1), resp.Data.Meta.AllCount)
|
||||
assert.Len(s.T(), resp.Data.Payload, 1)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestList_AppliesFinderFiltersAfterCounts() {
|
||||
assigneeID := uint(42)
|
||||
assigned := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, AssigneeID: &assigneeID, Status: "open", Labels: "vip", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(assigned).Error)
|
||||
unmatched := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "open", Labels: "standard", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(unmatched).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations?labels=vip&assignee_type=assigned", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Meta struct {
|
||||
AssignedCount int64 `json:"assigned_count"`
|
||||
UnassignedCount int64 `json:"unassigned_count"`
|
||||
AllCount int64 `json:"all_count"`
|
||||
} `json:"meta"`
|
||||
Payload []map[string]interface{} `json:"payload"`
|
||||
} `json:"data"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), int64(1), resp.Data.Meta.AllCount)
|
||||
assert.Equal(s.T(), int64(1), resp.Data.Meta.AssignedCount)
|
||||
assert.Equal(s.T(), int64(0), resp.Data.Meta.UnassignedCount)
|
||||
assert.Len(s.T(), resp.Data.Payload, 1)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestList_QuerySkipsStatusFilter() {
|
||||
resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(resolved).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, Content: "needle open", MessageType: string(model.MessageTypeIncoming)}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: resolved.ID, Content: "needle resolved", MessageType: string(model.MessageTypeOutgoing)}).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", s.accountURL()+"/conversations?q=needle&status=open", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Meta struct {
|
||||
AllCount int64 `json:"all_count"`
|
||||
} `json:"meta"`
|
||||
Payload []map[string]interface{} `json:"payload"`
|
||||
} `json:"data"`
|
||||
}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), int64(2), resp.Data.Meta.AllCount)
|
||||
assert.Len(s.T(), resp.Data.Payload, 2)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestList_InvalidAccountID() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/invalid/conversations", nil)
|
||||
|
||||
@@ -403,6 +403,60 @@ type ConversationMetaParams struct {
|
||||
RestrictToInboxes bool
|
||||
}
|
||||
|
||||
type ConversationFinderListParams struct {
|
||||
ConversationMetaParams
|
||||
AssigneeType string
|
||||
SortBy string
|
||||
UpdatedWithin *int
|
||||
}
|
||||
|
||||
// ListForFinder retrieves conversations using the same filtered base as ConversationFinder#perform.
|
||||
func (r *ConversationRepo) ListForFinder(ctx context.Context, accountID, userID uint, params ConversationFinderListParams, offset, limit int) ([]model.Conversation, error) {
|
||||
query := r.conversationMetaBaseQuery(ctx, accountID, userID, params.ConversationMetaParams)
|
||||
|
||||
switch params.AssigneeType {
|
||||
case "me":
|
||||
query = query.Where("conversations.assignee_id = ?", userID)
|
||||
case "unassigned":
|
||||
query = query.Where("conversations.assignee_id IS NULL")
|
||||
case "assigned":
|
||||
query = query.Where("conversations.assignee_id IS NOT NULL")
|
||||
}
|
||||
|
||||
if params.UpdatedWithin != nil {
|
||||
query = query.Where("conversations.updated_at > ?", time.Now().Add(-time.Duration(*params.UpdatedWithin)*time.Second))
|
||||
} else {
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
}
|
||||
|
||||
var conversations []model.Conversation
|
||||
err := query.Order(conversationFinderOrderClause(params.SortBy)).Find(&conversations).Error
|
||||
return conversations, err
|
||||
}
|
||||
|
||||
func conversationFinderOrderClause(sortBy string) string {
|
||||
switch sortBy {
|
||||
case "last_activity_at_asc":
|
||||
return "conversations.last_activity_at ASC"
|
||||
case "created_at_asc", "sort_on_created_at":
|
||||
return "conversations.created_at ASC"
|
||||
case "created_at_desc":
|
||||
return "conversations.created_at DESC"
|
||||
case "priority_asc":
|
||||
return "conversations.priority ASC"
|
||||
case "priority_desc", "sort_on_priority":
|
||||
return "conversations.priority DESC"
|
||||
case "waiting_since_asc", "sort_on_waiting_since":
|
||||
return "conversations.waiting_since ASC"
|
||||
case "waiting_since_desc":
|
||||
return "conversations.waiting_since DESC"
|
||||
case "priority_desc_created_at_asc":
|
||||
return "conversations.priority DESC, conversations.created_at ASC"
|
||||
default:
|
||||
return "conversations.last_activity_at DESC"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ConversationRepo) conversationMetaBaseQuery(ctx context.Context, accountID, userID uint, params ConversationMetaParams) *gorm.DB {
|
||||
query := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID)
|
||||
trimmedQuery := strings.TrimSpace(params.Query)
|
||||
|
||||
@@ -583,6 +583,68 @@ func TestConversationRepo_GetMeta_QuerySkipsStatusFilter(t *testing.T) {
|
||||
assert.Equal(t, int64(3), meta.UnassignedCount)
|
||||
}
|
||||
|
||||
func TestConversationRepo_ListForFinder_AppliesAssigneeAfterCountsBase(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewConversationRepo(db)
|
||||
assigneeID := uint(88)
|
||||
|
||||
account := &model.Account{Name: "ListFinderOrg", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "ListFinderInbox", ChannelType: "web_widget", ChannelID: 1}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "ListFinderContact"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
|
||||
assigned := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
|
||||
require.NoError(t, db.Model(assigned).Updates(map[string]any{"assignee_id": assigneeID, "labels": "vip"}).Error)
|
||||
unassigned := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
|
||||
require.NoError(t, db.Model(unassigned).Update("labels", "vip").Error)
|
||||
otherLabel := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
|
||||
require.NoError(t, db.Model(otherLabel).Update("labels", "standard").Error)
|
||||
|
||||
conversations, err := repo.ListForFinder(context.Background(), account.ID, assigneeID, ConversationFinderListParams{
|
||||
ConversationMetaParams: ConversationMetaParams{Labels: []string{"vip"}},
|
||||
AssigneeType: "assigned",
|
||||
}, 0, 25)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, conversations, 1)
|
||||
assert.Equal(t, assigned.ID, conversations[0].ID)
|
||||
|
||||
meta, err := repo.GetMeta(context.Background(), account.ID, assigneeID, ConversationMetaParams{Labels: []string{"vip"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), meta.AllCount)
|
||||
assert.Equal(t, int64(1), meta.AssignedCount)
|
||||
assert.Equal(t, int64(1), meta.UnassignedCount)
|
||||
}
|
||||
|
||||
func TestConversationRepo_ListForFinder_DefaultStatusAndQueryStatusSkip(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
repo := NewConversationRepo(db)
|
||||
|
||||
account := &model.Account{Name: "ListFinderQueryOrg", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "ListFinderQueryInbox", ChannelType: "web_widget", ChannelID: 1}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "ListFinderQueryContact"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
|
||||
openConv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
|
||||
resolvedConv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved")
|
||||
require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: openConv.ID, Content: "needle open", MessageType: string(model.MessageTypeIncoming)}).Error)
|
||||
require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: resolvedConv.ID, Content: "needle resolved", MessageType: string(model.MessageTypeOutgoing)}).Error)
|
||||
|
||||
defaultConversations, err := repo.ListForFinder(context.Background(), account.ID, 0, ConversationFinderListParams{}, 0, 25)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, defaultConversations, 1)
|
||||
assert.Equal(t, openConv.ID, defaultConversations[0].ID)
|
||||
|
||||
queryConversations, err := repo.ListForFinder(context.Background(), account.ID, 0, ConversationFinderListParams{
|
||||
ConversationMetaParams: ConversationMetaParams{Status: "open", Query: "needle"},
|
||||
}, 0, 25)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, queryConversations, 2)
|
||||
}
|
||||
|
||||
// ========== MarkUnread Tests ==========
|
||||
|
||||
func TestConversationRepo_MarkUnread(t *testing.T) {
|
||||
|
||||
@@ -702,7 +702,7 @@ type FilterParams struct {
|
||||
Labels string `json:"labels,omitempty" form:"labels"`
|
||||
Tags string `json:"tags,omitempty" form:"tags"` // custom tags (Chatwoot: same as labels via ActsAsTaggableOn)
|
||||
ConversationType string `json:"conversation_type,omitempty" form:"conversation_type" validate:"omitempty,oneof=mention participating unattended"`
|
||||
SortBy string `json:"sort_by,omitempty" form:"sort_by" validate:"omitempty,oneof=last_activity_at_asc last_activity_at_desc created_at_asc created_at_desc priority_asc priority_desc waiting_since_asc waiting_since_desc latest sort_on_created_at sort_on_priority sort_on_waiting_since"`
|
||||
SortBy string `json:"sort_by,omitempty" form:"sort_by" validate:"omitempty,oneof=last_activity_at_asc last_activity_at_desc created_at_asc created_at_desc priority_asc priority_desc waiting_since_asc waiting_since_desc priority_desc_created_at_asc latest sort_on_created_at sort_on_priority sort_on_waiting_since"`
|
||||
UpdatedWithin *int `json:"updated_within,omitempty" form:"updated_within"` // seconds
|
||||
Query string `json:"q,omitempty" form:"q"` // 1:1 Chatwoot: filter_by_query — search messages ILIKE
|
||||
SourceID string `json:"source_id,omitempty" form:"source_id"` // 1:1 Chatwoot: filter_by_source_id — contact_inbox.source_id
|
||||
@@ -880,6 +880,43 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListWithFinder retrieves conversations for the Chatwoot index/search finder contract.
|
||||
// Reference: Chatwoot ConversationFinder#perform.
|
||||
func (s *ConversationService) ListWithFinder(ctx context.Context, accountID, userID uint, params FilterParams, offset, limit int) (*FilterResult, error) {
|
||||
if err := pkgvalidator.ValidateStruct(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metaParams, err := s.conversationMetaParamsForUser(ctx, accountID, userID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, err := s.repo.GetMeta(ctx, accountID, userID, metaParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conversations, err := s.repo.ListForFinder(ctx, accountID, userID, repository.ConversationFinderListParams{
|
||||
ConversationMetaParams: metaParams,
|
||||
AssigneeType: params.AssigneeType,
|
||||
SortBy: params.SortBy,
|
||||
UpdatedWithin: params.UpdatedWithin,
|
||||
}, offset, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FilterResult{
|
||||
Conversations: conversations,
|
||||
Count: FilterCountMeta{
|
||||
MineCount: meta.MineCount,
|
||||
AssignedCount: meta.AssignedCount,
|
||||
UnassignedCount: meta.UnassignedCount,
|
||||
AllCount: meta.AllCount,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Search searches conversations by label or metadata within an account.
|
||||
func (s *ConversationService) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Conversation, int64, error) {
|
||||
return s.repo.Search(ctx, accountID, query, offset, limit, searchMode)
|
||||
@@ -924,6 +961,15 @@ func (s *ConversationService) UpdatePriority(ctx context.Context, accountID, id
|
||||
// GetMeta retrieves Chatwoot conversation sidebar counts for an account.
|
||||
// Reference: ConversationFinder#perform_meta_only and conversations/meta.json.jbuilder.
|
||||
func (s *ConversationService) GetMeta(ctx context.Context, accountID, userID uint, params FilterParams) (*repository.ConversationMeta, error) {
|
||||
metaParams, err := s.conversationMetaParamsForUser(ctx, accountID, userID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.repo.GetMeta(ctx, accountID, userID, metaParams)
|
||||
}
|
||||
|
||||
func (s *ConversationService) conversationMetaParamsForUser(ctx context.Context, accountID, userID uint, params FilterParams) (repository.ConversationMetaParams, error) {
|
||||
metaParams := repository.ConversationMetaParams{
|
||||
Status: params.Status,
|
||||
InboxID: params.InboxID,
|
||||
@@ -939,22 +985,22 @@ func (s *ConversationService) GetMeta(ctx context.Context, accountID, userID uin
|
||||
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
metaParams.RestrictToInboxes = true
|
||||
return s.repo.GetMeta(ctx, accountID, userID, metaParams)
|
||||
return metaParams, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return metaParams, err
|
||||
}
|
||||
if !accountUser.IsAdministrator() {
|
||||
ids, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return metaParams, err
|
||||
}
|
||||
metaParams.RestrictToInboxes = true
|
||||
metaParams.RestrictedInboxIDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.GetMeta(ctx, accountID, userID, metaParams)
|
||||
return metaParams, nil
|
||||
}
|
||||
|
||||
func splitConversationMetaLabels(labels string) []string {
|
||||
|
||||
Reference in New Issue
Block a user