feat(conversations): align meta counts

This commit is contained in:
2026-06-07 02:54:41 +08:00
parent 8591fe1d33
commit cdc0978476
7 changed files with 273 additions and 95 deletions
+8 -4
View File
@@ -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.
@@ -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(&params); 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.
@@ -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() {
+84 -45
View File
@@ -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.
+42 -20
View File
@@ -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)
}
}
+47 -4
View File
@@ -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
+39 -11
View File
@@ -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 ==========