diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 3b59ecf0..7fdd9133 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 P5.8b contact import DataImportJob parity, prepared as `feat(crm): queue contact imports`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(crm): queue contact imports`. -- Latest documentation/tooling checkpoint: this tracker update records durable contact import job parity from Chatwoot `contacts#import` plus `DataImportJob` behavior. No route artifacts change. +- 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. - 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: contact CSV imports now create pending `data_imports`, persist the uploaded CSV for worker replay, enqueue `contact:import` on the `low` queue when a WorkerPool is configured, and return immediately without creating contacts on the request path. Worker replay marks imports processing/completed/failed, updates record counters, and reuses existing CSV merge/label/custom-attribute behavior. Missing `import_file` now returns Chatwoot's exact `422 { error: "File is blank" }`. 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 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. - 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.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. | | 0 | P3.52 outgoing messages count builder parity | Implemented for reused v2 reports outgoing-message clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#outgoing_messages_count` and `V2::Reports::OutgoingMessagesCountBuilder` by returning empty `422` for invalid or blank `group_by`, and by grouping label counts by tag name before resolving the current account's label id. This prevents local tag ids from leaking when taggings reference a same-named label outside the account. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for missing-date `range` behavior, output ordering, nil inbox id serialization, or ActsAsTaggableOn context drift. | Focused Analytics handler and service outgoing-message tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -226,6 +227,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.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. | | P5.8b contact import DataImportJob parity | `internal/service/contact_service.go`, `internal/service/contact_import_worker.go`, `internal/handler/api/v1/contact_handler.go`, contact import service/handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/contacts_controller.rb#import`, `reference/chatwoot/spec/controllers/api/v1/accounts/contacts_controller_spec.rb`, `reference/chatwoot/app/jobs/data_import_job.rb`, `reference/chatwoot/app/services/data_import/contact_manager.rb` | Contact import now follows Chatwoot's durable import boundary: missing `import_file` returns `422 { error: "File is blank" }`; successful imports create pending `data_imports`, persist the uploaded CSV for replay in `import_config`, enqueue `contact:import` on the `low` queue when a WorkerPool exists, and worker replay transitions processing/completed/failed while reusing the CSV merge, label validation, and custom-attribute logic. | Review by `feat(crm): queue contact imports`; focused contact import service/handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -294,6 +296,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `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. | | `feat(reports): align inbox label matrix filters` | Advances P3.51 inbox-label matrix filter parity by matching Chatwoot `V2::Reports::InboxLabelMatrixBuilder`. GoChat now parses optional `since/until`, `inbox_ids`, and `label_ids` on `/reports/inbox_label_matrix`, filters the inbox and label axes before sorting, and counts only labels attached to account conversations in the selected inbox/date range. | `go test ./internal/service -run InboxLabelMatrix -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.51 moves to Review for current matrix builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2593,3 +2596,4 @@ Verification milestone gates: - 2026-06-07: P3.53 first-response distribution range checkpoint prepared as `feat(reports): align first response distribution range`; audited Chatwoot `V2::Reports::FirstResponseTimeDistributionBuilder` and `DateRangeHelper#range`. GoChat `/reports/first_response_time_distribution` now accepts missing or partial `since/until` as an all-time distribution and only applies the created-at range when both params are present. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change. - 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. diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index 94bb3a2b..5df2136f 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "errors" "net/http" "strconv" "strings" @@ -1035,11 +1036,15 @@ func (h *ConversationHandler) UnreadCounts(c *gin.Context) { return } - payload, svcErr := h.conversationSvc.GetUnreadCounts(c.Request.Context(), accountID) + payload, svcErr := h.conversationSvc.GetUnreadCounts(c.Request.Context(), accountID, currentUserID(c)) if svcErr != nil { + if errors.Is(svcErr, service.ErrConversationUnreadCountsFeatureNotEnabled) { + c.JSON(http.StatusForbidden, gin.H{"error": svcErr.Error()}) + return + } handleServiceError(c, svcErr) return } - response.OK(c, payload) + c.JSON(http.StatusOK, gin.H{"payload": payload}) } diff --git a/internal/handler/api/v1/conversation_handler_test.go b/internal/handler/api/v1/conversation_handler_test.go index e19652a9..7597c97f 100644 --- a/internal/handler/api/v1/conversation_handler_test.go +++ b/internal/handler/api/v1/conversation_handler_test.go @@ -49,6 +49,7 @@ type ConversationHandlerTestSuite struct { db *gorm.DB testAccount *model.Account testConv *model.Conversation + testUser *model.User } func (s *ConversationHandlerTestSuite) SetupSuite() { @@ -72,19 +73,28 @@ func (s *ConversationHandlerTestSuite) SetupSuite() { &model.Message{}, &model.Attachment{}, &model.InboxMember{}, + &model.AccountUser{}, + &model.CustomRole{}, + &model.Team{}, + &model.TeamMember{}, &model.Tag{}, &model.ConversationLabel{}, ) s.Require().NoError(err) // Create test account - 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(db.Create(account).Error) s.testAccount = account + user := &model.User{Name: "Conv Handler User", Email: "conv-handler@example.com"} + s.Require().NoError(db.Create(user).Error) + s.Require().NoError(db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + s.testUser = user // Create inbox and contact inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1} s.Require().NoError(db.Create(inbox).Error) + s.Require().NoError(db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"} s.Require().NoError(db.Create(contact).Error) @@ -110,6 +120,10 @@ func (s *ConversationHandlerTestSuite) SetupSuite() { // Setup router r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("user_id", user.ID) + c.Next() + }) s.router = r // Register routes @@ -406,6 +420,8 @@ func (s *ConversationHandlerTestSuite) TestUpdateCustomAttributes_ConversationNo // ========== UnreadCounts Handler Tests ========== func (s *ConversationHandlerTestSuite) TestUnreadCounts_Success() { + s.Require().NoError(s.db.Model(s.testAccount).Update("feature_flags", `{"conversation_unread_counts":true}`).Error) + w := httptest.NewRecorder() req, _ := http.NewRequest("GET", s.accountURL()+"/conversations/unread_counts", nil) s.router.ServeHTTP(w, req) @@ -413,16 +429,31 @@ func (s *ConversationHandlerTestSuite) TestUnreadCounts_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` - Data struct { + Payload struct { Inboxes map[string]int64 `json:"inboxes"` Labels map[string]int64 `json:"labels"` Teams map[string]int64 `json:"teams"` - } `json:"data"` + } `json:"payload"` } err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) - assert.True(s.T(), resp.Success) + assert.NotContains(s.T(), w.Body.String(), "success") + assert.NotNil(s.T(), resp.Payload.Inboxes) +} + +func (s *ConversationHandlerTestSuite) TestUnreadCounts_FeatureDisabled() { + account := &model.Account{Name: "Unread Disabled", Locale: "en", Active: true} + s.Require().NoError(s.db.Create(account).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: account.ID, UserID: s.testUser.ID, Role: "administrator"}).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/"+strconv.FormatUint(uint64(account.ID), 10)+"/conversations/unread_counts", nil) + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusForbidden, w.Code) + var resp map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), "Conversation unread counts feature not enabled for this account", resp["error"]) } func (s *ConversationHandlerTestSuite) TestUnreadCounts_InvalidAccountID() { diff --git a/internal/repository/conversation_repo.go b/internal/repository/conversation_repo.go index 51631bfd..855df95e 100644 --- a/internal/repository/conversation_repo.go +++ b/internal/repository/conversation_repo.go @@ -444,54 +444,83 @@ type UnreadCountResult struct { // UnreadCountByLabel holds unread conversation counts grouped by label name. type UnreadCountByLabel struct { - Label string `gorm:"column:label"` - Count int64 `gorm:"column:count"` + Key uint `gorm:"column:key"` + Count int64 `gorm:"column:count"` } // GetUnreadCountsByInbox returns the number of unread conversations per inbox // for a given account. A conversation is unread if agent_last_seen_at < last_message_at // or agent_last_seen_at is nil and the conversation has messages. // Reference: Chatwoot app/services/conversations/unread_counts/counter.rb -func (r *ConversationRepo) GetUnreadCountsByInbox(ctx context.Context, accountID uint) ([]UnreadCountResult, error) { +func (r *ConversationRepo) GetUnreadCountsByInbox(ctx context.Context, accountID uint, inboxIDs []uint, permissionMode string, userID uint) ([]UnreadCountResult, error) { var results []UnreadCountResult - err := r.db.WithContext(ctx).Model(&model.Conversation{}). + if len(inboxIDs) == 0 { + return results, nil + } + query := r.db.WithContext(ctx).Model(&model.Conversation{}). Select("inbox_id as key, COUNT(*) as count"). Where("account_id = ? AND status = ?", accountID, model.ConversationStatusOpen). - Where("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at"). - Group("inbox_id"). + Where("inbox_id IN ?", inboxIDs). + Where("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at") + query = applyUnreadPermissionScope(query, permissionMode, userID) + err := query.Group("inbox_id"). Find(&results).Error return results, err } // GetUnreadCountsByTeam returns the number of unread conversations per team // for a given account. -func (r *ConversationRepo) GetUnreadCountsByTeam(ctx context.Context, accountID uint) ([]UnreadCountResult, error) { +func (r *ConversationRepo) GetUnreadCountsByTeam(ctx context.Context, accountID uint, inboxIDs, teamIDs []uint, permissionMode string, userID uint) ([]UnreadCountResult, error) { var results []UnreadCountResult - err := r.db.WithContext(ctx).Model(&model.Conversation{}). + if len(inboxIDs) == 0 || len(teamIDs) == 0 { + return results, nil + } + query := r.db.WithContext(ctx).Model(&model.Conversation{}). Select("team_id as key, COUNT(*) as count"). Where("account_id = ? AND status = ? AND team_id IS NOT NULL", accountID, model.ConversationStatusOpen). - Where("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at"). - Group("team_id"). + Where("inbox_id IN ? AND team_id IN ?", inboxIDs, teamIDs). + Where("agent_last_seen_at IS NULL OR agent_last_seen_at < last_message_at") + query = applyUnreadPermissionScope(query, permissionMode, userID) + err := query.Group("team_id"). Find(&results).Error return results, err } // GetUnreadCountsByLabel returns the number of unread conversations per label // for a given account. Queries conversation_labels join table. -func (r *ConversationRepo) GetUnreadCountsByLabel(ctx context.Context, accountID uint) ([]UnreadCountByLabel, error) { +func (r *ConversationRepo) GetUnreadCountsByLabel(ctx context.Context, accountID uint, inboxIDs []uint, permissionMode string, userID uint) ([]UnreadCountByLabel, error) { var results []UnreadCountByLabel - err := r.db.WithContext(ctx). + if len(inboxIDs) == 0 { + return results, nil + } + query := r.db.WithContext(ctx). Table("conversation_labels"). - Select("tags.name as label, COUNT(*) as count"). + Select("conversation_labels.tag_id as key, COUNT(*) as count"). Joins("JOIN conversations ON conversations.id = conversation_labels.conversation_id"). Joins("JOIN tags ON tags.id = conversation_labels.tag_id"). Where("conversations.account_id = ? AND conversations.status = ?", accountID, model.ConversationStatusOpen). - Where("conversations.agent_last_seen_at IS NULL OR conversations.agent_last_seen_at < conversations.last_message_at"). - Group("tags.name"). + Where("conversations.inbox_id IN ?", inboxIDs). + Where("tags.show_on_sidebar = ?", true). + Where("conversations.agent_last_seen_at IS NULL OR conversations.agent_last_seen_at < conversations.last_message_at") + query = applyUnreadPermissionScope(query, permissionMode, userID) + err := query.Group("conversation_labels.tag_id"). Find(&results).Error return results, err } +func applyUnreadPermissionScope(query *gorm.DB, permissionMode string, userID uint) *gorm.DB { + switch permissionMode { + case "unassigned_and_mine": + return query.Where("conversations.assignee_id IS NULL OR conversations.assignee_id = ?", userID) + case "mine": + return query.Where("conversations.assignee_id = ?", userID) + case "none": + return query.Where("1 = 0") + default: + return query + } +} + // AssigneeWorkloadResult 表示每个agent的open conversations计数结果 // Reference: Chatwoot assignable_agents workload计算 type AssigneeWorkloadResult struct { diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index eb7a3466..86df4a23 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -1008,25 +1008,53 @@ func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accoun // UnreadCountsPayload holds the unread conversation counts payload response. // Reference: Chatwoot app/controllers/api/v1/accounts/conversations/unread_counts_controller.rb type UnreadCountsPayload struct { - Inboxes map[uint]int64 `json:"inboxes"` - Labels map[string]int64 `json:"labels"` - Teams map[uint]int64 `json:"teams"` + Inboxes map[uint]int64 `json:"inboxes"` + Labels map[uint]int64 `json:"labels"` + Teams map[uint]int64 `json:"teams"` } +var ErrConversationUnreadCountsFeatureNotEnabled = errors.New("Conversation unread counts feature not enabled for this account") + // GetUnreadCounts returns unread conversation counts grouped by inbox, label, and team. // Reference: Chatwoot app/services/conversations/unread_counts/counter.rb -func (s *ConversationService) GetUnreadCounts(ctx context.Context, accountID uint) (*UnreadCountsPayload, error) { - inboxCounts, err := s.repo.GetUnreadCountsByInbox(ctx, accountID) +func (s *ConversationService) GetUnreadCounts(ctx context.Context, accountID, userID uint) (*UnreadCountsPayload, error) { + if s == nil || s.repo == nil || s.repo.DB() == nil { + return nil, errors.New("conversation service not ready") + } + var account model.Account + if err := s.repo.DB().WithContext(ctx).Select("id", "feature_flags").First(&account, accountID).Error; err != nil { + return nil, err + } + if !featureFlagStringEnabled(account.FeatureFlags, "conversation_unread_counts") { + return nil, ErrConversationUnreadCountsFeatureNotEnabled + } + permissionMode, err := s.unreadCountsPermissionMode(ctx, accountID, userID) + if err != nil { + return nil, err + } + if permissionMode == "none" { + return emptyUnreadCountsPayload(), nil + } + inboxIDs, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID) + if err != nil { + return nil, err + } + teamIDs, err := s.visibleUnreadCountTeamIDs(ctx, accountID, userID) if err != nil { return nil, err } - teamCounts, err := s.repo.GetUnreadCountsByTeam(ctx, accountID) + inboxCounts, err := s.repo.GetUnreadCountsByInbox(ctx, accountID, inboxIDs, permissionMode, userID) if err != nil { return nil, err } - labelCounts, err := s.repo.GetUnreadCountsByLabel(ctx, accountID) + teamCounts, err := s.repo.GetUnreadCountsByTeam(ctx, accountID, inboxIDs, teamIDs, permissionMode, userID) + if err != nil { + return nil, err + } + + labelCounts, err := s.repo.GetUnreadCountsByLabel(ctx, accountID, inboxIDs, permissionMode, userID) if err != nil { return nil, err } @@ -1041,9 +1069,9 @@ func (s *ConversationService) GetUnreadCounts(ctx context.Context, accountID uin teams[r.Key] = r.Count } - labels := make(map[string]int64) + labels := make(map[uint]int64) for _, r := range labelCounts { - labels[r.Label] = r.Count + labels[r.Key] = r.Count } return &UnreadCountsPayload{ @@ -1053,6 +1081,97 @@ func (s *ConversationService) GetUnreadCounts(ctx context.Context, accountID uin }, nil } +func emptyUnreadCountsPayload() *UnreadCountsPayload { + return &UnreadCountsPayload{Inboxes: map[uint]int64{}, Labels: map[uint]int64{}, Teams: map[uint]int64{}} +} + +func (s *ConversationService) unreadCountsPermissionMode(ctx context.Context, accountID, userID uint) (string, error) { + if userID == 0 { + return "base", nil + } + var accountUser model.AccountUser + err := s.repo.DB().WithContext(ctx).Preload("CustomRole").Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "none", nil + } + if err != nil { + return "", err + } + if !accountUser.IsAgent() || !accountUser.HasCustomRole() { + return "base", nil + } + if accountUser.CustomRole == nil { + return "none", nil + } + permissions, err := accountUser.CustomRole.GetPermissionKeys() + if err != nil { + return "", err + } + if hasUnreadCountPermission(permissions, model.DimensionConversationManage) { + return "base", nil + } + if hasUnreadCountPermission(permissions, model.DimensionConversationUnassignedManage) { + return "unassigned_and_mine", nil + } + if hasUnreadCountPermission(permissions, model.DimensionConversationParticipatingManage) { + return "mine", nil + } + return "none", nil +} + +func hasUnreadCountPermission(permissions []model.PermissionDimension, permission model.PermissionDimension) bool { + for _, item := range permissions { + if item == permission { + return true + } + } + return false +} + +func (s *ConversationService) visibleUnreadCountInboxIDs(ctx context.Context, accountID, userID uint) ([]uint, error) { + var ids []uint + query := s.repo.DB().WithContext(ctx).Model(&model.Inbox{}).Where("account_id = ?", accountID) + 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) { + return ids, nil + } + if err != nil { + return nil, err + } + if !accountUser.IsAdministrator() { + query = query.Joins("INNER JOIN inbox_members ON inbox_members.inbox_id = inboxes.id").Where("inbox_members.user_id = ?", userID) + } + } + if err := query.Pluck("inboxes.id", &ids).Error; err != nil { + return nil, err + } + return ids, nil +} + +func (s *ConversationService) visibleUnreadCountTeamIDs(ctx context.Context, accountID, userID uint) ([]uint, error) { + var ids []uint + query := s.repo.DB().WithContext(ctx).Model(&model.Team{}).Where("account_id = ?", accountID) + 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) { + return ids, nil + } + if err != nil { + return nil, err + } + if !accountUser.IsAdministrator() { + query = query.Joins("INNER JOIN team_members ON team_members.team_id = teams.id").Where("team_members.user_id = ?", userID) + } + } + if err := query.Pluck("teams.id", &ids).Error; err != nil { + return nil, err + } + return ids, nil +} + // ToggleTyping broadcasts a typing status event for an agent in a conversation. // Reference: Chatwoot app/controllers/api/v1/accounts/conversations_controller.rb #toggle_typing_status func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conversationID uint, typingStatus string) error { diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index 45f184d8..177f915e 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -39,7 +39,10 @@ func setupConversationServiceTestDB(t *testing.T) *gorm.DB { &model.InboxMember{}, &model.ConversationLabel{}, &model.Team{}, + &model.TeamMember{}, &model.Tag{}, + &model.AccountUser{}, + &model.CustomRole{}, &model.SlaPolicy{}, &model.AppliedSLA{}, &model.SlaEvent{}, @@ -55,7 +58,7 @@ func setupConversationServiceTestDB(t *testing.T) *gorm.DB { func createConversationServiceTestAccount(t *testing.T, db *gorm.DB) *model.Account { t.Helper() - account := &model.Account{Name: "ConvSvcTestOrg", Locale: "en", Active: true} + account := &model.Account{Name: "ConvSvcTestOrg", Locale: "en", Active: true, FeatureFlags: `{"conversation_unread_counts":true}`} require.NoError(t, db.Create(account).Error) return account } @@ -517,7 +520,7 @@ func TestConversationService_UnreadCounts(t *testing.T) { require.NoError(t, db.Model(conv1).Update("last_message_at", lastMsgTime).Error) require.NoError(t, db.Model(conv2).Update("last_message_at", lastMsgTime).Error) - payload, err := svc.GetUnreadCounts(context.Background(), account.ID) + payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) assert.NoError(t, err) assert.NotNil(t, payload) @@ -543,8 +546,71 @@ func TestConversationService_UnreadCounts_NoUnread(t *testing.T) { require.NoError(t, db.Model(conv).Update("agent_last_seen_at", nowTime).Error) require.NoError(t, db.Model(conv).Update("last_message_at", lastMsgTime).Error) - payload, err := svc.GetUnreadCounts(context.Background(), account.ID) + payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) assert.NoError(t, err) assert.NotNil(t, payload) assert.Empty(t, payload.Inboxes) } + +func TestConversationService_UnreadCounts_FeatureDisabled(t *testing.T) { + svc, db := setupConversationService(t) + + account := &model.Account{Name: "UnreadDisabled", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + + payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) + assert.Nil(t, payload) + assert.ErrorIs(t, err, ErrConversationUnreadCountsFeatureNotEnabled) +} + +func TestConversationService_UnreadCounts_LabelIDsAndSidebarOnly(t *testing.T) { + svc, db := setupConversationService(t) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + show := true + hide := false + visibleLabel := &model.Tag{AccountID: account.ID, Name: "visible", ShowOnSidebar: &show} + hiddenLabel := &model.Tag{AccountID: account.ID, Name: "hidden", ShowOnSidebar: &hide} + require.NoError(t, db.Create(visibleLabel).Error) + require.NoError(t, db.Create(hiddenLabel).Error) + + lastMsgTime := int64(1700000100) + conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": int64(1700000000), "last_message_at": lastMsgTime}).Error) + require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: visibleLabel.ID}).Error) + require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: hiddenLabel.ID}).Error) + + payload, err := svc.GetUnreadCounts(context.Background(), account.ID, 0) + require.NoError(t, err) + assert.Equal(t, int64(1), payload.Labels[visibleLabel.ID]) + assert.NotContains(t, payload.Labels, hiddenLabel.ID) +} + +func TestConversationService_UnreadCounts_CustomRoleParticipatingScope(t *testing.T) { + svc, db := setupConversationService(t) + + account := createConversationServiceTestAccount(t, db) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + user := &model.User{Name: "Scoped Agent", Email: "scoped@example.com"} + require.NoError(t, db.Create(user).Error) + role := &model.CustomRole{AccountID: account.ID, Name: "Mine only"} + require.NoError(t, role.SetPermissionKeys([]model.PermissionDimension{model.DimensionConversationParticipatingManage})) + require.NoError(t, db.Create(role).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent", CustomRoleID: role.ID}).Error) + require.NoError(t, db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error) + + seenAt := int64(1700000000) + lastMsgTime := int64(1700000100) + mine := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + mine.AssigneeID = &user.ID + require.NoError(t, db.Model(mine).Updates(map[string]any{"assignee_id": user.ID, "agent_last_seen_at": seenAt, "last_message_at": lastMsgTime}).Error) + unassigned := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + require.NoError(t, db.Model(unassigned).Updates(map[string]any{"agent_last_seen_at": seenAt, "last_message_at": lastMsgTime}).Error) + + payload, err := svc.GetUnreadCounts(context.Background(), account.ID, user.ID) + require.NoError(t, err) + assert.Equal(t, int64(1), payload.Inboxes[inbox.ID]) +}