diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index e103d4c2..e2f4b50d 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 bot metrics builder parity checkpoint, prepared as `feat(reports): align bot metrics builder counts`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align bot metrics builder counts`. -- Latest documentation/tooling checkpoint: this tracker update records P3.50 bot metrics builder count parity plus the committed P3.49/P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`. +- Current tracking checkpoint: 2026-06-07 inbox-label matrix filter parity checkpoint, prepared as `feat(reports): align inbox label matrix filters`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align inbox label matrix filters`. +- Latest documentation/tooling checkpoint: this tracker update records P3.51 inbox-label matrix filter parity plus the committed P3.50/P3.49/P3.48/P3.47/P3.46/P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`. - 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: v2 reports bot metrics now match inspected Chatwoot `V2::Reports::BotMetricsBuilder` count semantics: bot messages are counted only for conversations created in the active-bot inbox range, bot resolutions are distinct conversations after excluding handoff conversations in the same range, and handoffs are distinct non-null conversations. P3.49 reports conversation agent-metric parity, P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: v2 reports inbox-label matrix now matches inspected Chatwoot `V2::Reports::InboxLabelMatrixBuilder` parameter semantics by honoring optional `since/until`, `inbox_ids`, and `label_ids` filters before building the sorted inbox/label matrix. P3.50 bot metrics builder count parity, P3.49 reports conversation agent-metric parity, P3.48 live grouped team-scope parity, P3.47 grouped error-shape parity, P3.46 summary aggregate parity, P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. 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, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. - `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox. - 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.51 inbox-label matrix filter parity | Implemented for reused v2 reports matrix clients: GoChat now follows inspected Chatwoot `V2::Reports::InboxLabelMatrixBuilder` by accepting optional `since/until`, `inbox_ids`, and `label_ids`; filtering the inbox and label axes before sorting by name/title; and counting only matching conversation labels whose conversations fall in the optional range and selected inboxes. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActsAsTaggableOn title/name divergence, array param encoding drift, or optional invalid-date controller behavior. | Focused Analytics handler and service matrix tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.50 bot metrics builder count parity | Implemented for reused v2 reports bot metric clients: GoChat now follows inspected Chatwoot `V2::Reports::BotMetricsBuilder`. `message_count` is scoped to outgoing messages whose `conversation_id` belongs to bot conversations created in the requested range, `resolution_rate` uses distinct resolved conversations after excluding any conversation with a handoff in range, and `handoff_rate` uses distinct non-null handoff conversations. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for `active_bot?` inbox activation drift, message sender-type filtering drift, or provider/bot-inbox status semantics beyond the inspected builder. | Focused Analytics bot metrics service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.49 reports conversation agent-metric parity | Implemented for legacy/reused v2 reports conversation clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#conversations`, `V2::ReportBuilder#conversation_metrics`, and `V2::ReportBuilder#agent_metrics`. Blank `type` returns empty `422`; `type=account` preserves raw account live metrics; non-account requests return paginated account-user objects with `id`, `name`, `email`, `thumbnail`, `availability`, and a `metric` object containing only `open` and `unattended`, sorted by open count descending. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for Kaminari pagination metadata assumptions, tie ordering, non-agent `type` scoping with `id`, or exact nil thumbnail/avatar behavior. | Focused Analytics handler and service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.48 live reports grouped team-scope parity | Implemented for reused live-report grouped metrics clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::LiveReportsController#load_conversations`, which applies optional `team_id` before both `conversation_metrics` and `grouped_conversation_metrics`. The grouped path now parses `team_id` and filters persisted conversations before grouping by `team_id` or `assignee_id`, preserving raw open/unattended/unassigned counts and nil-assignee buckets. | Keep in Review; reopen from B12 live-report smoke or fresh reference evidence for missing-team `404` body shape, authorization status drift, grouped ordering assumptions, or open/unattended scope changes. | Focused `LiveReportHandler` and Analytics live metrics tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -289,6 +290,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `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. | | `feat(reports): align bot metrics builder counts` | Advances P3.50 bot metrics builder parity by matching Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat now derives `message_count` from outgoing messages attached to in-range bot conversations, not every outgoing message in an active-bot inbox; `resolution_rate` uses distinct resolved conversations after excluding handoff conversations; and `handoff_rate` uses distinct non-null handoff conversations. | `go test ./internal/service -run 'AnalyticsBotMetrics\|AnalyticsReportsUsePersisted' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.50 moves to Review for current bot metrics builder evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): align conversation agent metrics` | Advances P3.49 reports conversation agent-metric parity by matching Chatwoot `Api::V2::Accounts::ReportsController#conversations` and `V2::ReportBuilder#conversation_metrics/#agent_metrics`. GoChat now returns empty `422` for blank `type`, keeps raw account live metrics for `type=account`, and returns paginated account-user agent objects for non-account requests instead of grouped assignee/team buckets; each agent row includes Chatwoot user fields plus `metric.open` and `metric.unattended`, sorted by open count descending. | `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; `go test ./internal/service -run AnalyticsReportsConversationsAgentMetrics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.49 moves to Review for current reports conversation evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): filter grouped live metrics by team` | Advances P3.48 live reports grouped team-scope parity by matching Chatwoot `Api::V2::Accounts::LiveReportsController#load_conversations`. GoChat grouped live-report metrics now parse optional `team_id` and apply that scope before grouping by `team_id` or `assignee_id`, so grouped assignee payloads exclude other-team conversations while retaining Chatwoot open/unattended/unassigned count fields and nil-assignee buckets. | `go test ./internal/handler/api/v1 -run LiveReportHandler -count=1`; `go test ./internal/service -run AnalyticsLiveConversationMetrics -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.48 moves to Review for current live-report team-scope evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2560,3 +2562,4 @@ Verification milestone gates: - 2026-06-07: P3.48 live reports grouped team-scope checkpoint prepared as `feat(reports): filter grouped live metrics by team`; audited Chatwoot v2 live reports controller `load_conversations` and grouped metrics behavior. GoChat grouped live reports now parse optional `team_id` and apply that scope before grouping by assignee/team, so other-team conversations no longer leak into `grouped_conversation_metrics` when the frontend supplies a team filter. Focused `LiveReportHandler` and Analytics live metrics tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.49 reports conversation agent-metric checkpoint prepared as `feat(reports): align conversation agent metrics`; audited Chatwoot v2 reports controller `conversations`, `V2::ReportBuilder#conversation_metrics/#agent_metrics`, `ReportHelper#scope`, and reused dashboard `reports.js` conversation metric caller. GoChat `/reports/conversations` now returns empty `422` for blank `type`, keeps raw account live metrics for `type=account`, and returns Chatwoot agent metric rows for non-account requests with paginated account users, availability, thumbnail, and `metric.open/unattended` sorted by open count instead of grouped assignee buckets. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.50 bot metrics builder count checkpoint prepared as `feat(reports): align bot metrics builder counts`; audited Chatwoot `V2::Reports::BotMetricsBuilder`. GoChat `/reports/bot_metrics` now counts outgoing bot messages only through in-range bot conversation IDs, computes bot resolutions as distinct non-null conversations after excluding handoff conversations, and computes handoffs as distinct non-null conversations, matching the builder rate denominators and avoiding active-bot inbox leakage from old conversations. Focused Analytics bot metrics tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.51 inbox-label matrix filter checkpoint prepared as `feat(reports): align inbox label matrix filters`; audited Chatwoot `V2::Reports::InboxLabelMatrixBuilder`. GoChat `/reports/inbox_label_matrix` now parses optional frontend `since/until`, `inbox_ids`, and `label_ids`, filters the returned inbox/label axes, and counts only selected account conversations in the requested date and inbox scope. Focused Analytics handler and service tests passed; escalated full `go test ./...` and `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/analytics_handler.go b/internal/handler/api/v1/analytics_handler.go index ef02a0a7..418665b0 100644 --- a/internal/handler/api/v1/analytics_handler.go +++ b/internal/handler/api/v1/analytics_handler.go @@ -4,6 +4,7 @@ import ( "encoding/csv" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -498,8 +499,12 @@ func (h *AnalyticsHandler) InboxLabelMatrix(c *gin.Context) { if !ok { return } + filter, ok := parseInboxLabelMatrixFilter(c) + if !ok { + return + } - result, err := h.svc.GetInboxLabelMatrix(c.Request.Context(), accountID) + result, err := h.svc.GetInboxLabelMatrix(c.Request.Context(), accountID, filter) if err != nil { applogger.L().Errorf("Inbox label matrix report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate inbox label matrix") @@ -509,6 +514,50 @@ func (h *AnalyticsHandler) InboxLabelMatrix(c *gin.Context) { c.JSON(http.StatusOK, result) } +func parseInboxLabelMatrixFilter(c *gin.Context) (service.InboxLabelMatrixFilter, bool) { + filter := service.InboxLabelMatrixFilter{ + InboxIDs: parseReportUintList(c, "inbox_ids"), + LabelIDs: parseReportUintList(c, "label_ids"), + } + sinceRaw := c.Query("since") + untilRaw := c.Query("until") + if sinceRaw == "" || untilRaw == "" { + return filter, true + } + since, err := parseChatwootReportTime(sinceRaw) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid since date format") + return service.InboxLabelMatrixFilter{}, false + } + until, err := parseChatwootReportTime(untilRaw) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid until date format") + return service.InboxLabelMatrixFilter{}, false + } + filter.Since = since + filter.Until = until + return filter, true +} + +func parseReportUintList(c *gin.Context, key string) []uint { + values := append([]string{}, c.QueryArray(key)...) + values = append(values, c.QueryArray(key+"[]")...) + ids := make([]uint, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + parsed, err := strconv.ParseUint(part, 10, 64) + if err == nil && parsed > 0 { + ids = append(ids, uint(parsed)) + } + } + } + return ids +} + // FirstResponseTimeDistribution returns first response time distribution. // GET /api/v1/accounts/:account_id/reports/first_response_time_distribution // Reference: Chatwoot reports#first_response_time_distribution diff --git a/internal/handler/api/v1/analytics_handler_test.go b/internal/handler/api/v1/analytics_handler_test.go index bf6194cd..80e43c8b 100644 --- a/internal/handler/api/v1/analytics_handler_test.go +++ b/internal/handler/api/v1/analytics_handler_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -73,6 +74,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() { accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic) accounts.GET("/reports/conversations", s.handler.Conversations) accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary) + accounts.GET("/reports/inbox_label_matrix", s.handler.InboxLabelMatrix) s.router = r } @@ -469,6 +471,39 @@ func (s *AnalyticsHandlerTestSuite) TestConversations_AgentTypeReturnsChatwootAg s.Equal("https://example.com/low.png", payload[1]["thumbnail"]) } +func (s *AnalyticsHandlerTestSuite) TestInboxLabelMatrix_ParsesFrontendFilters() { + since := parseTime("2025-01-01T00:00:00Z") + inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} + otherInbox := model.Inbox{AccountID: s.accountID, Name: "Other", ChannelType: "web_widget", ChannelID: 2, Enabled: true} + s.Require().NoError(s.db.Create(&inbox).Error) + s.Require().NoError(s.db.Create(&otherInbox).Error) + label := model.Tag{AccountID: s.accountID, Name: "vip"} + otherLabel := model.Tag{AccountID: s.accountID, Name: "bug"} + s.Require().NoError(s.db.Create(&label).Error) + s.Require().NoError(s.db.Create(&otherLabel).Error) + conv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(time.Hour)}} + oldConv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(-time.Hour)}} + otherConv := model.Conversation{AccountID: s.accountID, InboxID: otherInbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(time.Hour)}} + s.Require().NoError(s.db.Create(&conv).Error) + s.Require().NoError(s.db.Create(&oldConv).Error) + s.Require().NoError(s.db.Create(&otherConv).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conv.ID, TagID: label.ID}).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conv.ID, TagID: otherLabel.ID}).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: oldConv.ID, TagID: label.ID}).Error) + s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: otherConv.ID, TagID: label.ID}).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/inbox_label_matrix?since=1735689600&until=1735776000&inbox_ids[]="+strconv.FormatUint(uint64(inbox.ID), 10)+"&label_ids="+strconv.FormatUint(uint64(label.ID), 10), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var payload map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Equal([]interface{}{[]interface{}{float64(1)}}, payload["matrix"]) + s.Require().Len(payload["inboxes"], 1) + s.Require().Len(payload["labels"], 1) +} + // ========== Nil service guard ========== func (s *AnalyticsHandlerTestSuite) TestNilService() { diff --git a/internal/service/analytics_p513_test.go b/internal/service/analytics_p513_test.go index d027cc02..bb567d61 100644 --- a/internal/service/analytics_p513_test.go +++ b/internal/service/analytics_p513_test.go @@ -169,12 +169,49 @@ func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing. assert.Equal(t, inbox.ID, outRows[0]["id"]) assert.Equal(t, int64(1), outRows[0]["outgoing_messages_count"]) - matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID) + matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID, InboxLabelMatrixFilter{}) require.NoError(t, err) matrixMap := matrix.(map[string]interface{}) assert.Equal(t, [][]int64{{1}}, matrixMap["matrix"]) } +func TestAnalyticsInboxLabelMatrixHonorsFiltersAndRange(t *testing.T) { + db, svc, account, inbox, contact, _, _ := setupAnalyticsP513Test(t) + since := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC) + until := since.Add(24 * time.Hour) + otherInbox := &model.Inbox{AccountID: account.ID, Name: "Other", ChannelType: "web_widget", ChannelID: 2, Enabled: true} + require.NoError(t, db.Create(otherInbox).Error) + keepLabel := &model.Tag{AccountID: account.ID, Name: "keep"} + skipLabel := &model.Tag{AccountID: account.ID, Name: "skip"} + require.NoError(t, db.Create(keepLabel).Error) + require.NoError(t, db.Create(skipLabel).Error) + inRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + outOfRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + otherInboxConversation := &model.Conversation{AccountID: account.ID, InboxID: otherInbox.ID, ContactID: contact.ID, ChannelType: otherInbox.ChannelType, Channel: otherInbox.ChannelType} + require.NoError(t, db.Create(inRange).Error) + require.NoError(t, db.Create(outOfRange).Error) + require.NoError(t, db.Create(otherInboxConversation).Error) + require.NoError(t, db.Model(inRange).Update("created_at", since.Add(time.Hour)).Error) + require.NoError(t, db.Model(outOfRange).Update("created_at", since.Add(-time.Hour)).Error) + require.NoError(t, db.Model(otherInboxConversation).Update("created_at", since.Add(2*time.Hour)).Error) + require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: inRange.ID, TagID: keepLabel.ID}).Error) + require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: inRange.ID, TagID: skipLabel.ID}).Error) + require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: outOfRange.ID, TagID: keepLabel.ID}).Error) + require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: otherInboxConversation.ID, TagID: keepLabel.ID}).Error) + + matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID, InboxLabelMatrixFilter{ + Since: since, + Until: until, + InboxIDs: []uint{inbox.ID}, + LabelIDs: []uint{keepLabel.ID}, + }) + require.NoError(t, err) + payload := matrix.(map[string]interface{}) + assert.Equal(t, [][]int64{{1}}, payload["matrix"]) + require.Len(t, payload["inboxes"], 1) + require.Len(t, payload["labels"], 1) +} + func TestAnalyticsTimeseriesAndRollupWorker(t *testing.T) { db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC) diff --git a/internal/service/analytics_query_helpers.go b/internal/service/analytics_query_helpers.go index a26247a2..558a69ef 100644 --- a/internal/service/analytics_query_helpers.go +++ b/internal/service/analytics_query_helpers.go @@ -773,17 +773,25 @@ func (s *AnalyticsService) botMetricDistinctCounts(ctx context.Context, db *gorm return resolutions, int64(len(handoffIDs)), nil } -func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) (map[string]interface{}, error) { +func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint, filter InboxLabelMatrixFilter) (map[string]interface{}, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var inboxes []model.Inbox - if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&inboxes).Error; err != nil { + inboxQ := db.WithContext(ctx).Where("account_id = ?", accountID) + if len(filter.InboxIDs) > 0 { + inboxQ = inboxQ.Where("id IN ?", filter.InboxIDs) + } + if err := inboxQ.Order("name ASC").Find(&inboxes).Error; err != nil { return nil, err } var tags []model.Tag - if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&tags).Error; err != nil { + tagQ := db.WithContext(ctx).Where("account_id = ?", accountID) + if len(filter.LabelIDs) > 0 { + tagQ = tagQ.Where("id IN ?", filter.LabelIDs) + } + if err := tagQ.Order("name ASC").Find(&tags).Error; err != nil { return nil, err } type countRow struct { @@ -793,12 +801,23 @@ func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) } var rows []countRow if len(inboxes) > 0 && len(tags) > 0 { - if err := db.WithContext(ctx).Table("conversation_labels"). + inboxIDs := make([]uint, 0, len(inboxes)) + for _, inbox := range inboxes { + inboxIDs = append(inboxIDs, inbox.ID) + } + labelIDs := make([]uint, 0, len(tags)) + for _, tag := range tags { + labelIDs = append(labelIDs, tag.ID) + } + countsQ := db.WithContext(ctx).Table("conversation_labels"). Select("conversations.inbox_id AS inbox_id, conversation_labels.tag_id AS tag_id, COUNT(*) AS count"). Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id"). - Where("conversation_labels.account_id = ?", accountID). - Group("conversations.inbox_id, conversation_labels.tag_id"). - Scan(&rows).Error; err != nil { + Where("conversation_labels.account_id = ? AND conversations.account_id = ?", accountID, accountID). + Where("conversations.inbox_id IN ? AND conversation_labels.tag_id IN ?", inboxIDs, labelIDs) + if !filter.Since.IsZero() && !filter.Until.IsZero() { + countsQ = countsQ.Where("conversations.created_at >= ? AND conversations.created_at < ?", filter.Since, filter.Until) + } + if err := countsQ.Group("conversations.inbox_id, conversation_labels.tag_id").Scan(&rows).Error; err != nil { return nil, err } } diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index b08bea27..9244ad93 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -116,6 +116,14 @@ type ReportAgentConversationMetric struct { Metric map[string]int64 `json:"metric"` } +// InboxLabelMatrixFilter mirrors V2::Reports::InboxLabelMatrixBuilder params. +type InboxLabelMatrixFilter struct { + Since time.Time + Until time.Time + InboxIDs []uint + LabelIDs []uint +} + // GetSummary returns account-level aggregated metrics for a date range. func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since, until time.Time) (*SummaryResponse, error) { if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil { @@ -452,8 +460,8 @@ func (s *AnalyticsService) GetBotMetrics(ctx context.Context, accountID uint, si // GetInboxLabelMatrix returns inbox-label matrix data. // Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder -func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint) (interface{}, error) { - return s.inboxLabelMatrix(ctx, accountID) +func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint, filter InboxLabelMatrixFilter) (interface{}, error) { + return s.inboxLabelMatrix(ctx, accountID, filter) } // GetFirstResponseTimeDistribution returns first response time distribution.