From cdc09784760adadc87bf37a2a7401e8cc93a6feb Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 02:54:41 +0800 Subject: [PATCH] feat(conversations): align meta counts --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 12 +- .../handler/api/v1/conversation_handler.go | 16 ++- .../api/v1/conversation_handler_test.go | 48 +++++-- internal/repository/conversation_repo.go | 129 ++++++++++++------ internal/repository/conversation_repo_test.go | 62 ++++++--- internal/service/conversation_service.go | 51 ++++++- internal/service/conversation_service_test.go | 50 +++++-- 7 files changed, 273 insertions(+), 95 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 7fdd9133..8fed4d71 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.54 conversation unread-count parity, prepared as `feat(conversations): align unread count payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align unread count payloads`. -- Latest documentation/tooling checkpoint: this tracker update records reused sidebar unread-count parity from Chatwoot `UnreadCountsController` and `Conversations::UnreadCounts::Counter`. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.55 conversation meta payload/filter parity, prepared as `feat(conversations): align meta counts`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align meta counts`. +- Latest documentation/tooling checkpoint: this tracker update records reused conversation sidebar meta parity from Chatwoot `ConversationsController#meta`, `ConversationFinder#perform_meta_only`, and `meta.json.jbuilder`. 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 unread counts now match the reused sidebar store contract by returning raw `{ payload: { inboxes, labels, teams } }`, enforcing the `conversation_unread_counts` account feature gate with Chatwoot's `403` error, returning label ids only for sidebar-visible labels, and scoping counts through current account-user visibility plus custom-role `conversation_manage`, `conversation_unassigned_manage`, and `conversation_participating_manage` permissions. 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 meta now returns Chatwoot raw `{ meta: { mine_count, assigned_count, unassigned_count, all_count } }`, defaults to open conversations, ignores `assignee_type` for meta-only counts like `ConversationFinder#perform_meta_only`, applies status/inbox/team/labels/conversation_type filters, and scopes non-admin agents to assigned inboxes. 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.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. | | 0 | P3.54 conversation unread-count payload/permission parity | Implemented for reused sidebar unread badges: GoChat now follows inspected Chatwoot `Api::V1::Accounts::Conversations::UnreadCountsController` and `Conversations::UnreadCounts::Counter` by returning raw `{ payload }`, enforcing the `conversation_unread_counts` feature flag, using label ids for sidebar-visible labels, and applying current-user inbox/team visibility plus custom-role permission modes. | Keep in Review; reopen from B12 sidebar smoke or fresh reference evidence for Redis cache-builder timing, ActionCable unread-count notification throttling, or deeper team/inbox membership edge cases. | Focused conversation service/handler unread-count tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P5.8b contact import DataImportJob parity | Implemented for reused CRM import flow: GoChat now follows inspected Chatwoot `Api::V1::Accounts::ContactsController#import` missing-file error and moves successful imports behind a durable `contact:import` job when a WorkerPool is configured. The pending `data_imports` row carries the uploaded CSV bytes in `import_config` for worker replay until Go has a fuller ActiveStorage-equivalent attachment path. | Keep in Review; reopen from B12 CRM smoke or fresh reference evidence for ActiveStorage attachment metadata, CSV parser edge cases, import notification behavior, or exact `DataImport::ContactManager` validation drift. | Focused contact import service/handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.53 first-response distribution range parity | Implemented for reused v2 reports distribution clients: GoChat now follows inspected Chatwoot `V2::Reports::FirstResponseTimeDistributionBuilder` and `DateRangeHelper#range` by treating missing or partial `since/until` as no date filter, while still applying the half-open created-at range when both values are present. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for exact SQL aggregation behavior, invalid timestamp exceptions, or channel type bucketing drift. | Focused Analytics handler and service first-response distribution tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -227,6 +228,7 @@ These rows are the executable development plan from this point forward. A checkp | P3.32 WhatsApp authorization parity | `internal/service/whatsapp_authorization_service.go`, `internal/handler/api/v1/inbox_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, WhatsApp authorization service tests | `reference/chatwoot/config/routes.rb:338-339`, `reference/chatwoot/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb`, `reference/chatwoot/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb`, `reference/chatwoot/app/services/whatsapp/embedded_signup_service.rb`, `reference/chatwoot/app/services/whatsapp/channel_creation_service.rb`, `reference/chatwoot/app/services/whatsapp/reauthorization_service.rb`, `reference/chatwoot/app/services/whatsapp/token_exchange_service.rb`, `reference/chatwoot/app/services/whatsapp/phone_info_service.rb`, `reference/chatwoot/app/services/whatsapp/token_validation_service.rb`, `reference/chatwoot/app/javascript/dashboard/api/channel/whatsappChannel.js` | Done. WhatsApp embedded signup now matches the reused dashboard boundary: the account route is tracked and registered; authenticated agents/admins can create or reauthorize; missing required params return Chatwoot-shaped `422` errors; Meta code exchange, phone info, and token WABA-access checks run through a fakeable HTTP boundary; new channels create WhatsApp Cloud inboxes with embedded-signup provider config and webhook setup; reauthorization validates phone-number continuity, refreshes access token/phone/provider config, clears `reauthorization_required`, updates inbox channel config/name, and returns raw `{ success, id, name, channel_type, message? }` payloads. | Review by `feat(channels): align whatsapp authorization`; focused WhatsApp authorization service tests cover create, reauthorize, validation, and provider failures; handler/router/route-parity focused tests pass; route dump/parity regenerated to `TOTAL: 948` and `398 exact, 13 parameter-compatible, 0 missing out of 411`; full `go test ./...` and `git diff --check` must pass. | | P3.24 Slack integration parity | `internal/handler/api/v1/slack_integration_handler.go`, `internal/service/slack_integration_service.go`, `internal/repository/integration_hook_repo.go`, `internal/handler/api/v1/integration_hook_handler.go`, `cmd/route_parity/main.go`, Slack handler/service tests | `reference/chatwoot/config/routes.rb:350-352`, `reference/chatwoot/app/controllers/api/v1/accounts/integrations/slack_controller.rb`, `reference/chatwoot/lib/integrations/slack/hook_builder.rb`, `reference/chatwoot/lib/integrations/slack/channel_builder.rb`, `reference/chatwoot/app/views/api/v1/accounts/integrations/slack/create.json.jbuilder`, `reference/chatwoot/app/views/api/v1/accounts/integrations/slack/update.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_app.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_hook.json.jbuilder`, `reference/chatwoot/spec/requests/api/v1/accounts/integrations/slack_request_spec.rb`, `reference/chatwoot/app/javascript/dashboard/api/integrations.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/integrations.js` | Done. Chatwoot Slack singleton behavior is implemented for the reused dashboard flow: frontend no-trailing `POST/PATCH/DELETE` routes and Rails `PUT` update are registered alongside trailing aliases; create accepts `code`/`inbox_id`, exchanges OAuth against Slack, persists disabled `app_id: slack` hooks with access tokens; update accepts `reference_id`, fetches private/public Slack channels with cursor pagination, joins public channels, persists `reference_id`, `settings.channel_name`, and enabled status only when the channel exists; create/update return the raw app partial with hooks; list-all returns raw Slack channel arrays; invalid channel returns `422 { error: "Invalid slack channel. Please try again" }`; delete returns empty `200 OK`. | Review by `feat(integrations): align slack parity`; focused handler tests cover no-trailing create raw app payload, empty delete, and PUT route availability; service tests cover OAuth exchange redirect/body, disabled hook creation, channel fetch/update/join, invalid/not-found, and raw channel lists through a fake Slack client; route parity is `379 exact, 13 parameter-compatible, 0 missing out of 392`; escalated full `go test ./...` and `git diff --check` passed. | | P3.23 nested contact inbox creation parity | `internal/handler/api/v1/contact_handler.go`, `internal/service/contact_inbox_service.go`, `internal/repository/contact_inbox_repo.go`, `internal/handler/api/v1/crm_serializer.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, nested contact inbox handler/service/repository tests | `reference/chatwoot/config/routes.rb:212`, `reference/chatwoot/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb`, `reference/chatwoot/app/controllers/concerns/hmac_concern.rb`, `reference/chatwoot/app/builders/contact_inbox_builder.rb`, `reference/chatwoot/app/views/api/v1/accounts/contacts/contact_inboxes/create.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_contact_inbox.json.jbuilder`, `reference/chatwoot/spec/controllers/api/v1/accounts/contacts/contact_inboxes_controller_spec.rb` | Done. Chatwoot `ContactInboxBuilder` behavior is implemented for `POST /api/v1/accounts/:account_id/contacts/:contact_id/contact_inboxes`: raw JSON/form/query params provide `inbox_id`, optional `source_id`, and `hmac_verified`; contact and inbox resolution is account-scoped; missing source IDs are generated by supported channel (`api`/`web_widget` UUID, email, sms phone, whatsapp phone without `+`, twilio sms/whatsapp medium); existing contact+inbox+source rows are returned idempotently; `hmac_verified` is set on creation; tokens are generated; inbox is preloaded; and the response is only `{ source_id, inbox: inbox_slim }`. | Review by `feat(contacts): align contact inbox creation`; focused handler tests cover raw payload shape, HMAC creation, generated source IDs, email idempotency, cross-account inbox rejection, and missing-phone Twilio failure; service tests cover WhatsApp/Twilio generation and idempotency; repository tests cover contact+inbox+source lookup; route parity, full `go test ./...`, and `git diff --check` passed. | +| P3.55 conversation meta payload/filter parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, `internal/repository/conversation_repo.go`, conversation repository/service/handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#meta`, `reference/chatwoot/app/finders/conversation_finder.rb`, `reference/chatwoot/app/views/api/v1/accounts/conversations/meta.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/conversations/actions.js` | Conversation meta now matches the reused dashboard count contract: `GET /conversations/meta` returns raw `{ meta }` with `mine_count`, `assigned_count`, `unassigned_count`, and `all_count`; defaults to open conversations; preserves Chatwoot meta-only counts before `assignee_type` filtering; applies `status`, `inbox_id`, `team_id`, `labels`, and `conversation_type`; and scopes non-admin users to assigned inboxes. | Review by `feat(conversations): align meta counts`; focused repository/service/handler tests cover raw payload, default-open counts, assignee-type behavior, label/conversation-type filters, and non-admin visibility; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.54 conversation unread-count payload/permission parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, `internal/repository/conversation_repo.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb`, `reference/chatwoot/app/services/conversations/unread_counts/counter.rb`, `reference/chatwoot/app/javascript/dashboard/api/conversations.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/conversationUnreadCounts.js` | Conversation unread counts now match the reused dashboard sidebar contract: the endpoint returns raw `{ payload }` instead of the local `{ success, data }` envelope, rejects disabled accounts with Chatwoot's `403` error, filters labels to `show_on_sidebar`, serializes label ids, limits visible inbox/team ids by current user membership for non-admins, and applies Chatwoot custom-role count modes for all/unassigned+mine/mine/no access. | Review by `feat(conversations): align unread count payloads`; focused service tests cover feature gate, label ids/sidebar filtering, and custom-role participating scope; handler tests cover raw payload and feature-gate error; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.2a invitation/confirmation mail parity | `internal/service/profile_service.go`, `internal/service/profile_confirmation_mailer.go`, `internal/handler/api/v1/profile_handler.go`, `internal/service/agent_service.go`, `internal/repository/agent_repo.go`, `internal/model/user.go`, `migrations/000032_add_users_unconfirmed_email.*`, profile/agent handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/agents_controller.rb`, `reference/chatwoot/app/builders/agent_builder.rb`, `reference/chatwoot/app/views/devise/mailer/confirmation_instructions.html.erb`, `reference/chatwoot/spec/mailers/confirmation_instructions_spec.rb`, `reference/chatwoot/spec/enterprise/mailers/devise_mailer_spec.rb` for non-SAML invitation wording only | Done. A shared fakeable confirmation mailer builds Chatwoot-shaped confirmation/invitation payloads; profile resend persists confirmation/reset tokens and delivers no-op/confirmation/invitation states; newly created invited agents get workspace invitation mail; `unconfirmed_email` is modeled for email-update branch routing; environment SMTP remains a no-op when not configured. SSO/SAML/LDAP/OIDC mail variants stay excluded. | Review by `feat(profile): send confirmation invitations`; focused tests cover confirmed no-op, normal confirmation mail, invited workspace invitation mail, agent creation/inviter context, hashed reset-token persistence, and no network in default tests; full `go test ./...` and `git diff --check` passed. | | P5.11a Captain document crawl/schedule | `internal/service/captain_document_service.go`, `internal/service/captain_document_worker.go`, `internal/app/bootstrap.go` | `reference/chatwoot/enterprise/app/jobs/captain/documents/crawl_job.rb`, `schedule_syncs_job.rb`, `perform_sync_job.rb`, Firecrawl/simple parser jobs | Durable schedule/crawl producers and handlers with fakeable crawl/parser boundaries. Missing provider config is a failed `crawl_disabled` state, not placeholder success. | Review by `feat(captain): queue document crawl jobs`; focused worker tests prove enqueue, replay, account scope, idempotent scheduler, and disabled/failure states. | @@ -296,6 +298,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `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. | | `feat(conversations): align unread count payloads` | Advances P3.54 with Chatwoot sidebar unread-count parity. `GET /conversations/unread_counts` now returns raw `{ payload: { inboxes, labels, teams } }`, enforces the `conversation_unread_counts` feature flag with the reference `403` error, emits sidebar label ids instead of label names, and scopes counts by visible inbox/team membership plus custom-role permission modes. | `go test ./internal/service -run 'ConversationService_UnreadCounts' -count=1`; `go test ./internal/handler/api/v1 -run 'ConversationHandlerTestSuite/TestUnreadCounts' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'UnreadCounts\|ConversationRepo\|ConversationService\|ConversationHandlerTestSuite' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.54 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(reports): align first response distribution range` | Advances P3.53 first-response distribution range parity by matching Chatwoot `V2::Reports::FirstResponseTimeDistributionBuilder` and `DateRangeHelper#range`. GoChat now allows `/reports/first_response_time_distribution` without `since/until`, treats partial ranges as no range filter, and still filters reporting events by `created_at >= since AND created_at < until` when both values are present. | `go test ./internal/service -run FirstResponseTimeDistribution -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.53 moves to Review for current distribution builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): align outgoing message counts` | Advances P3.52 outgoing messages count builder parity by matching Chatwoot `Api::V2::Accounts::ReportsController#outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder`. GoChat now returns empty `422` for invalid or blank `group_by`, and label grouping now counts by tag name then resolves the current account's label id, instead of returning the raw joined tag id. | `go test ./internal/service -run OutgoingMessagesCount -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.52 moves to Review for current outgoing-message builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2597,3 +2600,4 @@ Verification milestone gates: - 2026-06-07: Documentation checkpoint prepared as `docs: refresh placeholder audit`; refreshed Phase 6 placeholder evidence and `docs/parity/frontend_smoke_report.md`. The narrow `chatwootParityStub` scan still finds only public webhook nil-handler fallbacks, and the broader placeholder/TODO scan finds no reused-frontend critical account/contact/conversation/message/inbox/widget/public handler placeholder. `scripts/parity_frontend_smoke.sh --check` passed after rerunning with workspace-local `GOCACHE`, `GOTMPDIR`, and `TMPDIR` because `/tmp` was full; `git diff --check` passed. Next default action remains Phase 2/3 drift audit unless the full B12 live stack is available. - 2026-06-07: P5.8b contact import DataImportJob checkpoint prepared as `feat(crm): queue contact imports`; audited Chatwoot contacts import controller/specs plus `DataImportJob`/contact manager behavior. GoChat contact imports now return the exact missing-file `422 { error: "File is blank" }`, create pending `data_imports` with replayable CSV payloads, enqueue `contact:import` jobs on the low queue when workers are configured, and process imports asynchronously while preserving existing merge, label validation, custom-attribute, status, and counter behavior. Focused contact service and handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.54 conversation unread-count checkpoint prepared as `feat(conversations): align unread count payloads`; audited Chatwoot unread counts controller, `Conversations::UnreadCounts::Counter`, and reused dashboard `conversationUnreadCounts` store. GoChat now returns raw `{ payload }`, enforces the feature flag with exact `403` error, emits sidebar label ids, and scopes unread badge counts by visible inbox/team membership plus custom-role permission modes. Focused repository/service/handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 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. diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index 5df2136f..b428c827 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -635,13 +635,25 @@ func (h *ConversationHandler) Meta(c *gin.Context) { return } - meta, svcErr := h.conversationSvc.GetMeta(c.Request.Context(), accountID) + var params service.FilterParams + if err := c.ShouldBindQuery(¶ms); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) + return + } + if params.Labels == "" { + labels := append(c.QueryArray("labels"), c.QueryArray("labels[]")...) + if len(labels) > 0 { + params.Labels = strings.Join(labels, ",") + } + } + + meta, svcErr := h.conversationSvc.GetMeta(c.Request.Context(), accountID, currentUserID(c), params) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, meta) + c.JSON(http.StatusOK, gin.H{"meta": meta}) } // Unread marks a conversation as unread by resetting agent_last_seen_at. diff --git a/internal/handler/api/v1/conversation_handler_test.go b/internal/handler/api/v1/conversation_handler_test.go index 7597c97f..df5e3ef8 100644 --- a/internal/handler/api/v1/conversation_handler_test.go +++ b/internal/handler/api/v1/conversation_handler_test.go @@ -158,17 +158,20 @@ func (s *ConversationHandlerTestSuite) TearDownTest() { s.db.Exec("DELETE FROM contact_inboxes") s.db.Exec("DELETE FROM contacts") s.db.Exec("DELETE FROM inbox_members") + s.db.Exec("DELETE FROM account_users") s.db.Exec("DELETE FROM inboxes") s.db.Exec("DELETE FROM messages") s.db.Exec("DELETE FROM accounts") // Re-seed base data - account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true} + account := &model.Account{Name: "ConvHandlerTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`} s.Require().NoError(s.db.Create(account).Error) s.testAccount = account + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: account.ID, UserID: s.testUser.ID, Role: "administrator"}).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(s.db.Create(inbox).Error) + s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: s.testUser.ID}).Error) contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"} s.Require().NoError(s.db.Create(contact).Error) @@ -193,17 +196,44 @@ func (s *ConversationHandlerTestSuite) TestMeta_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` - Data struct { - TotalCount int64 `json:"total_count"` - StatusCounts map[string]int64 `json:"status_counts"` - LabelCounts map[string]int64 `json:"label_counts"` - } `json:"data"` + Meta struct { + MineCount int64 `json:"mine_count"` + AssignedCount int64 `json:"assigned_count"` + UnassignedCount int64 `json:"unassigned_count"` + AllCount int64 `json:"all_count"` + } `json:"meta"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) - assert.True(s.T(), resp.Success) - assert.Equal(s.T(), int64(1), resp.Data.TotalCount) + assert.NotContains(s.T(), w.Body.String(), "success") + assert.Equal(s.T(), int64(1), resp.Meta.AllCount) + assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount) +} + +func (s *ConversationHandlerTestSuite) TestMeta_FiltersStatusAndIgnoresAssigneeTypeForCounts() { + assigned := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(assigned).Error) + resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, AssigneeID: &s.testUser.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/meta?assignee_type=assigned", nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var resp struct { + Meta struct { + MineCount int64 `json:"mine_count"` + AssignedCount int64 `json:"assigned_count"` + UnassignedCount int64 `json:"unassigned_count"` + AllCount int64 `json:"all_count"` + } `json:"meta"` + } + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), int64(1), resp.Meta.MineCount) + assert.Equal(s.T(), int64(1), resp.Meta.AssignedCount) + assert.Equal(s.T(), int64(1), resp.Meta.UnassignedCount) + assert.Equal(s.T(), int64(2), resp.Meta.AllCount) } func (s *ConversationHandlerTestSuite) TestMeta_InvalidAccountID() { diff --git a/internal/repository/conversation_repo.go b/internal/repository/conversation_repo.go index 855df95e..203c8612 100644 --- a/internal/repository/conversation_repo.go +++ b/internal/repository/conversation_repo.go @@ -359,65 +359,104 @@ func (r *ConversationRepo) BulkUpdateStatus(ctx context.Context, accountID uint, Update("status", status).Error } -// ConversationMeta holds aggregated statistics for conversations. +// ConversationMeta holds Chatwoot conversation sidebar counts. type ConversationMeta struct { - StatusCounts map[string]int64 `json:"status_counts"` - LabelCounts map[string]int64 `json:"label_counts"` - TotalCount int64 `json:"total_count"` + MineCount int64 `json:"mine_count"` + AssignedCount int64 `json:"assigned_count"` + UnassignedCount int64 `json:"unassigned_count"` + AllCount int64 `json:"all_count"` } // GetMeta retrieves aggregated conversation metadata for an account. -// Reference: Chatwoot conversations_controller#meta -func (r *ConversationRepo) GetMeta(ctx context.Context, accountID uint) (*ConversationMeta, error) { - meta := &ConversationMeta{ - StatusCounts: make(map[string]int64), - LabelCounts: make(map[string]int64), - } +// Reference: Chatwoot ConversationFinder#perform_meta_only. +func (r *ConversationRepo) GetMeta(ctx context.Context, accountID, userID uint, params ConversationMetaParams) (*ConversationMeta, error) { + base := r.conversationMetaBaseQuery(ctx, accountID, userID, params) - // Total count - var total int64 - if err := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Count(&total).Error; err != nil { + var mineCount, unassignedCount, allCount int64 + if err := base.Session(&gorm.Session{}).Where("conversations.assignee_id = ?", userID).Count(&mineCount).Error; err != nil { return nil, err } - meta.TotalCount = total - - // Status distribution - type statusRow struct { - Status string - Count int64 - } - var statusRows []statusRow - if err := r.db.WithContext(ctx).Model(&model.Conversation{}). - Select("status, count(*) as count"). - Where("account_id = ?", accountID). - Group("status").Find(&statusRows).Error; err != nil { + if err := base.Session(&gorm.Session{}).Where("conversations.assignee_id IS NULL").Count(&unassignedCount).Error; err != nil { return nil, err } - for _, row := range statusRows { - meta.StatusCounts[row.Status] = row.Count - } - - // Label distribution — parse comma-separated labels field - type labelRow struct { - Labels string - } - var labelRows []labelRow - if err := r.db.WithContext(ctx).Model(&model.Conversation{}). - Select("labels"). - Where("account_id = ? AND labels != '' AND labels IS NOT NULL", accountID). - Find(&labelRows).Error; err != nil { + if err := base.Session(&gorm.Session{}).Count(&allCount).Error; err != nil { return nil, err } - for _, row := range labelRows { - for _, label := range strings.Split(row.Labels, ",") { - label = strings.TrimSpace(label) - if label != "" { - meta.LabelCounts[label]++ - } + + return &ConversationMeta{ + MineCount: mineCount, + AssignedCount: allCount - unassignedCount, + UnassignedCount: unassignedCount, + AllCount: allCount, + }, nil +} + +type ConversationMetaParams struct { + Status string + InboxID *uint + TeamID *uint + Labels []string + ConversationType string + RestrictedInboxIDs []uint + RestrictToInboxes bool +} + +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) + + if params.RestrictToInboxes { + if len(params.RestrictedInboxIDs) == 0 { + query = query.Where("1 = 0") + } else { + query = query.Where("conversations.inbox_id IN ?", params.RestrictedInboxIDs) } } - return meta, nil + if params.Status == "all" { + // Chatwoot skips status filtering for status=all. + } else if params.Status == "" { + query = query.Where("conversations.status = ?", model.ConversationStatusOpen) + } else { + query = query.Where("conversations.status = ?", params.Status) + } + + if params.InboxID != nil { + query = query.Where("conversations.inbox_id = ?", *params.InboxID) + } + if params.TeamID != nil { + query = query.Where("conversations.team_id = ?", *params.TeamID) + } + if len(params.Labels) > 0 { + conditions := make([]string, 0, len(params.Labels)) + args := make([]any, 0, len(params.Labels)) + for _, label := range params.Labels { + label = strings.TrimSpace(label) + if label == "" { + continue + } + conditions = append(conditions, "conversations.labels LIKE ?") + args = append(args, "%"+label+"%") + } + if len(conditions) > 0 { + tagSubquery := r.db.WithContext(ctx). + Table("conversation_labels"). + Select("conversation_labels.conversation_id"). + Joins("JOIN tags ON tags.id = conversation_labels.tag_id"). + Where("conversation_labels.account_id = ? AND tags.name IN ?", accountID, params.Labels) + query = query.Where(r.db.Where(strings.Join(conditions, " OR "), args...).Or("conversations.id IN (?)", tagSubquery)) + } + } + + switch params.ConversationType { + case "mention": + query = query.Where("conversations.id IN (SELECT conversation_id FROM mentions WHERE account_id = ? AND user_id = ?)", accountID, userID) + case "participating": + query = query.Where("conversations.id IN (SELECT conversation_id FROM conversation_participants WHERE user_id = ?)", userID) + case "unattended": + query = query.Where("conversations.first_reply_created_at IS NULL OR conversations.waiting_since IS NOT NULL") + } + + return query } // MarkUnread marks a conversation as unread by resetting agent_last_seen_at to nil. diff --git a/internal/repository/conversation_repo_test.go b/internal/repository/conversation_repo_test.go index c6d65c6f..c9cd2495 100644 --- a/internal/repository/conversation_repo_test.go +++ b/internal/repository/conversation_repo_test.go @@ -435,6 +435,7 @@ func TestConversationRepo_CountByAccount(t *testing.T) { func TestConversationRepo_GetMeta(t *testing.T) { db := setupTestDB(t) repo := NewConversationRepo(db) + userID := uint(42) account := &model.Account{Name: "MetaTestOrg", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) @@ -445,24 +446,20 @@ func TestConversationRepo_GetMeta(t *testing.T) { contact := &model.Contact{AccountID: account.ID, Name: "MetaTestContact"} require.NoError(t, db.Create(contact).Error) - // Create conversations with different statuses and labels + // Chatwoot meta defaults to open conversations and returns assignee buckets. createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + assigned := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + require.NoError(t, db.Model(assigned).Update("assignee_id", userID).Error) conv2 := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved") - // Update labels on conv2 require.NoError(t, db.Model(conv2).Update("labels", "bug,critical").Error) createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "pending") - conv4 := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") - require.NoError(t, db.Model(conv4).Update("labels", "bug,feature").Error) - meta, err := repo.GetMeta(context.Background(), account.ID) + meta, err := repo.GetMeta(context.Background(), account.ID, userID, ConversationMetaParams{}) assert.NoError(t, err) - assert.Equal(t, int64(4), meta.TotalCount) - assert.Equal(t, int64(2), meta.StatusCounts["open"]) - assert.Equal(t, int64(1), meta.StatusCounts["resolved"]) - assert.Equal(t, int64(1), meta.StatusCounts["pending"]) - assert.Equal(t, int64(2), meta.LabelCounts["bug"]) - assert.Equal(t, int64(1), meta.LabelCounts["critical"]) - assert.Equal(t, int64(1), meta.LabelCounts["feature"]) + assert.Equal(t, int64(2), meta.AllCount) + assert.Equal(t, int64(1), meta.MineCount) + assert.Equal(t, int64(1), meta.AssignedCount) + assert.Equal(t, int64(1), meta.UnassignedCount) } func TestConversationRepo_GetMeta_EmptyAccount(t *testing.T) { @@ -472,11 +469,10 @@ func TestConversationRepo_GetMeta_EmptyAccount(t *testing.T) { account := &model.Account{Name: "MetaEmptyOrg", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) - meta, err := repo.GetMeta(context.Background(), account.ID) + meta, err := repo.GetMeta(context.Background(), account.ID, 0, ConversationMetaParams{}) assert.NoError(t, err) - assert.Equal(t, int64(0), meta.TotalCount) - assert.Empty(t, meta.StatusCounts) - assert.Empty(t, meta.LabelCounts) + assert.Equal(t, int64(0), meta.AllCount) + assert.Equal(t, int64(0), meta.UnassignedCount) } func TestConversationRepo_GetMeta_WrongAccount(t *testing.T) { @@ -495,10 +491,36 @@ func TestConversationRepo_GetMeta_WrongAccount(t *testing.T) { createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") // Query a non-existent account ID — should return empty meta - meta, err := repo.GetMeta(context.Background(), 9999) + meta, err := repo.GetMeta(context.Background(), 9999, 0, ConversationMetaParams{}) assert.NoError(t, err) - assert.Equal(t, int64(0), meta.TotalCount) - assert.Empty(t, meta.StatusCounts) + assert.Equal(t, int64(0), meta.AllCount) +} + +func TestConversationRepo_GetMeta_FiltersStatusLabelsAndConversationType(t *testing.T) { + db := setupTestDB(t) + repo := NewConversationRepo(db) + userID := uint(77) + + account := &model.Account{Name: "MetaFilterOrg", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "MetaFilterInbox", ChannelType: "web_widget", ChannelID: 1} + require.NoError(t, db.Create(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "MetaFilterContact"} + require.NoError(t, db.Create(contact).Error) + + match := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + firstReply := int64(1699999999) + require.NoError(t, db.Model(match).Updates(map[string]any{"labels": "vip", "assignee_id": userID, "first_reply_created_at": firstReply}).Error) + waiting := int64(1700000000) + unattended := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + require.NoError(t, db.Model(unattended).Updates(map[string]any{"labels": "vip", "waiting_since": waiting}).Error) + resolved := createTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved") + require.NoError(t, db.Model(resolved).Update("labels", "vip").Error) + + meta, err := repo.GetMeta(context.Background(), account.ID, userID, ConversationMetaParams{Labels: []string{"vip"}, ConversationType: "unattended"}) + require.NoError(t, err) + assert.Equal(t, int64(1), meta.AllCount) + assert.Equal(t, int64(1), meta.UnassignedCount) } // ========== MarkUnread Tests ========== @@ -642,4 +664,4 @@ func TestConversationRepo_UpdateCustomAttributes_EmptyJSON(t *testing.T) { updated, err := repo.FindByID(context.Background(), conv.ID) assert.NoError(t, err) assert.NotNil(t, updated.CustomAttributes) -} \ No newline at end of file +} diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 86df4a23..82d8d5ab 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -921,10 +921,53 @@ func (s *ConversationService) UpdatePriority(ctx context.Context, accountID, id return conversation, nil } -// GetMeta retrieves aggregated conversation metadata (status counts, label counts) for an account. -// Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#meta -func (s *ConversationService) GetMeta(ctx context.Context, accountID uint) (*repository.ConversationMeta, error) { - return s.repo.GetMeta(ctx, accountID) +// 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 := repository.ConversationMetaParams{ + Status: params.Status, + InboxID: params.InboxID, + TeamID: params.TeamID, + Labels: splitConversationMetaLabels(params.Labels), + ConversationType: params.ConversationType, + } + + if userID != 0 { + var accountUser model.AccountUser + 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) + } + if err != nil { + return nil, err + } + if !accountUser.IsAdministrator() { + ids, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID) + if err != nil { + return nil, err + } + metaParams.RestrictToInboxes = true + metaParams.RestrictedInboxIDs = ids + } + } + + return s.repo.GetMeta(ctx, accountID, userID, metaParams) +} + +func splitConversationMetaLabels(labels string) []string { + if strings.TrimSpace(labels) == "" { + return nil + } + parts := strings.Split(labels, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out } // MarkUnread marks a conversation as unread by setting agent_last_seen_at to diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index 177f915e..41cd656a 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -277,20 +277,25 @@ func TestConversationService_GetMeta(t *testing.T) { account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) + user := &model.User{Name: "Meta Agent", Email: "meta-agent@example.com"} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) - // Create conversations with different statuses - createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + // Chatwoot meta defaults to open conversations and reports assignee buckets. + openUnassigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + assert.NotZero(t, openUnassigned.ID) + openAssigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + require.NoError(t, db.Model(openAssigned).Update("assignee_id", user.ID).Error) conv2 := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "resolved") require.NoError(t, db.Model(conv2).Update("labels", "support").Error) createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "pending") - meta, err := svc.GetMeta(context.Background(), account.ID) + meta, err := svc.GetMeta(context.Background(), account.ID, user.ID, FilterParams{}) assert.NoError(t, err) - assert.Equal(t, int64(3), meta.TotalCount) - assert.Equal(t, int64(1), meta.StatusCounts["open"]) - assert.Equal(t, int64(1), meta.StatusCounts["resolved"]) - assert.Equal(t, int64(1), meta.StatusCounts["pending"]) - assert.Equal(t, int64(1), meta.LabelCounts["support"]) + assert.Equal(t, int64(2), meta.AllCount) + assert.Equal(t, int64(1), meta.MineCount) + assert.Equal(t, int64(1), meta.AssignedCount) + assert.Equal(t, int64(1), meta.UnassignedCount) } func TestConversationService_GetMeta_EmptyAccount(t *testing.T) { @@ -298,10 +303,33 @@ func TestConversationService_GetMeta_EmptyAccount(t *testing.T) { account := createConversationServiceTestAccount(t, db) - meta, err := svc.GetMeta(context.Background(), account.ID) + meta, err := svc.GetMeta(context.Background(), account.ID, 0, FilterParams{}) assert.NoError(t, err) - assert.Equal(t, int64(0), meta.TotalCount) - assert.Empty(t, meta.StatusCounts) + assert.Equal(t, int64(0), meta.AllCount) + assert.Equal(t, int64(0), meta.UnassignedCount) +} + +func TestConversationService_GetMeta_FiltersByLabelAndVisibility(t *testing.T) { + svc, db := setupConversationService(t) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + hiddenInbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + user := &model.User{Name: "Scoped Meta Agent", Email: "scoped-meta@example.com"} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error) + require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) + + visible := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + require.NoError(t, db.Model(visible).Updates(map[string]any{"labels": "vip", "assignee_id": user.ID}).Error) + hidden := createConversationServiceTestConversation(t, db, account.ID, hiddenInbox.ID, contact.ID, "open") + require.NoError(t, db.Model(hidden).Update("labels", "vip").Error) + + meta, err := svc.GetMeta(context.Background(), account.ID, user.ID, FilterParams{Labels: "vip"}) + require.NoError(t, err) + assert.Equal(t, int64(1), meta.AllCount) + assert.Equal(t, int64(1), meta.MineCount) } // ========== MarkUnread Tests ==========