feat(conversations): align unread count payloads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user