feat(reports): align conversation agent metrics
This commit is contained in:
@@ -49,11 +49,11 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-07 live reports team-scope parity checkpoint, prepared as `feat(reports): filter grouped live metrics by team`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): filter grouped live metrics by team`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records P3.48 live reports grouped team-scope parity plus the committed 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 reports conversation agent-metric parity checkpoint, prepared as `feat(reports): align conversation agent metrics`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align conversation agent metrics`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records P3.49 reports conversation agent-metric parity plus the committed 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: live reports grouped metrics now match inspected Chatwoot `Api::V2::Accounts::LiveReportsController#load_conversations` team scope behavior by honoring optional `team_id` before grouped live metrics are calculated. `grouped_conversation_metrics?group_by=assignee_id&team_id=...` now excludes conversations from other teams while preserving nil-assignee and open/unattended/unassigned payload shape. 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 conversation metrics now match inspected Chatwoot `Api::V2::Accounts::ReportsController#conversations` and `V2::ReportBuilder#conversation_metrics/#agent_metrics` behavior: blank `type` returns empty `422`, `type=account` keeps raw live account metrics, and non-account requests now return paginated account-user agent objects sorted by open conversation count with `id/name/email/thumbnail/availability/metric` instead of grouped assignee buckets. 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.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. |
|
||||
| 0 | P3.47 live reports grouped error-shape parity | Implemented for reused live-report grouped metrics clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::LiveReportsController#set_group_scope` and `spec/controllers/api/v2/accounts/live_reports_controller_spec.rb` by returning raw `422 { error: "invalid group_by" }` for invalid grouped live-report requests. Focused tests also cover assignee grouping payloads, including the nil-assignee bucket, open/unattended/unassigned counts, and absence of the local success envelope. | Keep in Review; reopen from B12 live-report smoke or fresh reference evidence for authorization status drift, team lookup `404` behavior, grouped ordering assumptions, or pending/open scope drift. | Focused `LiveReportHandler` tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
| 0 | P3.46 report summary aggregate parity | Implemented for reused v2 reports summary clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#build_summary`, `V2::Reports::Conversations::MetricBuilder#summary/#bot_summary`, `Reports::RawDataSource#aggregate`, and `Reports::ReportMetricRegistry`. `/reports/summary` returns raw Chatwoot metric keys with a `previous` object instead of the local `{ metrics: [...] }` rollup envelope, uses current/previous Unix-second ranges, honors report `type/id` filters, and switches average values to `value_in_business_hours` when requested. `/reports/bot_summary` now applies aggregate raw count strategies: handoffs are distinct non-null conversations, and bot resolutions count event rows after excluding handoff conversations in the same scoped range. | Keep in Review; reopen from B12 reports summary smoke, fresh reference evidence for rollup-backed `Reports::DataSource.for`, invalid timezone/range controller behavior, or summary dimension scope drift for team/label/report-type filters. | Focused Analytics service bot-summary tests, Analytics handler summary tests, escalated full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
|
||||
@@ -287,6 +288,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `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. |
|
||||
| `feat(reports): align live grouped errors` | Advances P3.47 live reports grouped error-shape parity by matching Chatwoot `Api::V2::Accounts::LiveReportsController#set_group_scope` and live-report controller specs. GoChat now returns raw `422 { error: "invalid group_by" }` for invalid `/live_reports/grouped_conversation_metrics` requests instead of the local error envelope, and focused tests cover grouped assignee payloads with nil-assignee buckets and open/unattended/unassigned counts. | `go test ./internal/handler/api/v1 -run LiveReportHandler -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.47 moves to Review for current live-report grouped error evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(reports): align summary aggregates` | Advances P3.46 report summary aggregate parity by matching Chatwoot `Api::V2::Accounts::ReportsController#build_summary`, `V2::Reports::Conversations::MetricBuilder`, `Reports::RawDataSource#aggregate`, and `ReportMetricRegistry`. GoChat `/reports/summary` now returns raw current metric keys plus `previous`, computes current/previous range aggregates from persisted conversations/messages/reporting events, honors report `type/id` dimensions and business-hours averages, and makes `bot_summary` aggregate counts follow the same `distinct_conversation` and `exclude_bot_handoffs` raw count strategies as the current timeseries path. | `go test ./internal/service -run 'Analytics.*Summary\|BotSummary\|BotTimeseries\|AnalyticsReports' -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; escalated full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.46 moves to Review for current summary aggregate evidence; continue Phase 2/3 drift audit for report builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
@@ -2554,3 +2556,4 @@ Verification milestone gates:
|
||||
- 2026-06-07: P3.46 report summary aggregate checkpoint prepared as `feat(reports): align summary aggregates`; audited Chatwoot v2 reports controller summary builder, conversation metric builder, raw data-source aggregate, metric registry, and reused dashboard `reports.js` summary/bot-summary callers. GoChat `/reports/summary` now returns raw Chatwoot metric keys plus `previous`, derives current/previous aggregates from persisted rows, honors report `type/id` filters and business-hours averages, and fixes `/reports/bot_summary` aggregate counting so handoffs are distinct non-null conversations while bot resolutions count event rows after excluding handoff conversations. Focused Analytics service and handler tests plus escalated full `go test ./...` passed; `git diff --check` passed. No route artifacts change.
|
||||
- 2026-06-07: P3.47 live reports grouped error checkpoint prepared as `feat(reports): align live grouped errors`; audited Chatwoot v2 live reports controller `set_group_scope` and request specs. GoChat grouped live reports now return raw `422 { error: "invalid group_by" }` for invalid `group_by` instead of the local error envelope, and focused handler tests cover assignee grouping with nil-assignee buckets and Chatwoot open/unattended/unassigned counts. Focused `LiveReportHandler` tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
|
||||
- 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.
|
||||
|
||||
@@ -416,11 +416,17 @@ func (h *AnalyticsHandler) Conversations(c *gin.Context) {
|
||||
reportType := c.Query("type")
|
||||
if reportType == "" {
|
||||
// Reference: Chatwoot returns head :unprocessable_entity if type is blank
|
||||
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "type parameter is required")
|
||||
c.Status(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
page := 1
|
||||
if rawPage := c.Query("page"); rawPage != "" {
|
||||
if parsed, err := strconv.Atoi(rawPage); err == nil && parsed > 0 {
|
||||
page = parsed
|
||||
}
|
||||
}
|
||||
|
||||
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, time.Time{}, time.Time{})
|
||||
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, page)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Conversations report: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations report")
|
||||
|
||||
@@ -40,6 +40,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
|
||||
s.Require().NoError(db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.User{},
|
||||
&model.AccountUser{},
|
||||
&model.Inbox{},
|
||||
&model.Team{},
|
||||
&model.Tag{},
|
||||
@@ -70,6 +71,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
|
||||
accounts.GET("/reports/labels", s.handler.LabelMetrics)
|
||||
accounts.GET("/reports/teams", s.handler.TeamMetrics)
|
||||
accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic)
|
||||
accounts.GET("/reports/conversations", s.handler.Conversations)
|
||||
accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary)
|
||||
s.router = r
|
||||
}
|
||||
@@ -88,6 +90,7 @@ func (s *AnalyticsHandlerTestSuite) SetupTest() {
|
||||
s.db.Exec("DELETE FROM tags")
|
||||
s.db.Exec("DELETE FROM teams")
|
||||
s.db.Exec("DELETE FROM inboxes")
|
||||
s.db.Exec("DELETE FROM account_users")
|
||||
s.db.Exec("DELETE FROM users")
|
||||
}
|
||||
|
||||
@@ -416,6 +419,56 @@ func (s *AnalyticsHandlerTestSuite) TestConversationsSummary_ReturnsCSVDownload(
|
||||
s.Equal("1", rows[2][2])
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestConversations_MissingTypeReturnsEmptyUnprocessableEntity() {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/conversations", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
||||
s.Empty(w.Body.String())
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestConversations_AgentTypeReturnsChatwootAgentMetrics() {
|
||||
agentOne := model.User{AccountID: s.accountID, Name: "Low", Email: "low@example.com", Password: "secret", AvatarURL: "https://example.com/low.png", Active: true}
|
||||
agentTwo := model.User{AccountID: s.accountID, Name: "High", Email: "high@example.com", Password: "secret", Active: true}
|
||||
s.Require().NoError(s.db.Create(&agentOne).Error)
|
||||
s.Require().NoError(s.db.Create(&agentTwo).Error)
|
||||
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agentOne.ID, Role: "agent", Availability: "offline"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agentTwo.ID, Role: "agent", Availability: "online"}).Error)
|
||||
inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
|
||||
s.Require().NoError(s.db.Create(&inbox).Error)
|
||||
lowFirstReply := int64(1735689700)
|
||||
for i := 0; i < 2; i++ {
|
||||
conv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agentTwo.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(&conv).Error)
|
||||
}
|
||||
lowConv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agentOne.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", FirstReplyCreatedAt: &lowFirstReply}
|
||||
s.Require().NoError(s.db.Create(&lowConv).Error)
|
||||
pendingForHigh := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agentTwo.ID, Status: string(model.ConversationStatusPending), ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(&pendingForHigh).Error)
|
||||
unassigned := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(&unassigned).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/conversations?type=agent&page=1", 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.Require().Len(payload, 2)
|
||||
s.Equal("High", payload[0]["name"])
|
||||
s.Equal("high@example.com", payload[0]["email"])
|
||||
s.Equal("online", payload[0]["availability"])
|
||||
metric := payload[0]["metric"].(map[string]interface{})
|
||||
s.Equal(float64(2), metric["open"])
|
||||
s.Equal(float64(2), metric["unattended"])
|
||||
s.NotContains(metric, "unassigned")
|
||||
s.NotContains(metric, "pending")
|
||||
s.Equal("Low", payload[1]["name"])
|
||||
s.Equal("https://example.com/low.png", payload[1]["thumbnail"])
|
||||
}
|
||||
|
||||
// ========== Nil service guard ==========
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestNilService() {
|
||||
|
||||
@@ -90,6 +90,31 @@ func TestAnalyticsLiveConversationMetricsAreDerivedFromConversations(t *testing.
|
||||
assert.Equal(t, int64(1), grouped[1]["open"])
|
||||
}
|
||||
|
||||
func TestAnalyticsReportsConversationsAgentMetricsMatchChatwootShape(t *testing.T) {
|
||||
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
|
||||
second := &model.User{AccountID: account.ID, Name: "Second", Email: "second@example.com", Password: "secret", AvatarURL: "https://example.com/second.png", Active: true}
|
||||
require.NoError(t, db.Create(second).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: second.ID, Role: string(model.AccountUserRoleAgent), Availability: "online"}).Error)
|
||||
firstReply := int64(1760000000)
|
||||
require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, FirstReplyCreatedAt: &firstReply}).Error)
|
||||
require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &second.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error)
|
||||
require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &second.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error)
|
||||
require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &second.ID, Status: string(model.ConversationStatusPending), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error)
|
||||
require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error)
|
||||
|
||||
result, err := svc.GetConversationsByType(context.Background(), account.ID, "agent", 1)
|
||||
require.NoError(t, err)
|
||||
agents := result.([]ReportAgentConversationMetric)
|
||||
require.Len(t, agents, 2)
|
||||
assert.Equal(t, second.ID, agents[0].ID)
|
||||
assert.Equal(t, "Second", agents[0].Name)
|
||||
assert.Equal(t, "second@example.com", agents[0].Email)
|
||||
assert.Equal(t, "https://example.com/second.png", agents[0].Thumbnail)
|
||||
assert.Equal(t, "online", agents[0].Availability)
|
||||
assert.Equal(t, map[string]int64{"open": 2, "unattended": 2}, agents[0].Metric)
|
||||
assert.Equal(t, map[string]int64{"open": 1, "unattended": 0}, agents[1].Metric)
|
||||
}
|
||||
|
||||
func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing.T) {
|
||||
db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t)
|
||||
since := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -588,17 +588,73 @@ func (s *AnalyticsService) aggregateEventCount(ctx context.Context, accountID ui
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string) (interface{}, error) {
|
||||
switch reportType {
|
||||
case "account":
|
||||
return s.liveConversationMetrics(ctx, accountID, 0)
|
||||
case "agent":
|
||||
return s.groupedLiveConversationMetrics(ctx, accountID, "assignee_id", 0)
|
||||
case "team":
|
||||
return s.groupedLiveConversationMetrics(ctx, accountID, "team_id", 0)
|
||||
default:
|
||||
func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string, page int) (interface{}, error) {
|
||||
if reportType == "account" {
|
||||
return s.liveConversationMetrics(ctx, accountID, 0)
|
||||
}
|
||||
return s.agentConversationMetrics(ctx, accountID, page)
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) agentConversationMetrics(ctx context.Context, accountID uint, page int) ([]ReportAgentConversationMetric, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
const perPage = 25
|
||||
|
||||
var accountUsers []model.AccountUser
|
||||
if err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("account_id = ?", accountID).
|
||||
Order("id ASC").
|
||||
Limit(perPage).
|
||||
Offset((page - 1) * perPage).
|
||||
Find(&accountUsers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]ReportAgentConversationMetric, 0, len(accountUsers))
|
||||
for _, accountUser := range accountUsers {
|
||||
metric, metricErr := s.liveConversationMetricsForAssignee(ctx, accountID, accountUser.UserID)
|
||||
if metricErr != nil {
|
||||
return nil, metricErr
|
||||
}
|
||||
result = append(result, ReportAgentConversationMetric{
|
||||
ID: accountUser.User.ID,
|
||||
Name: accountUser.User.Name,
|
||||
Email: accountUser.User.Email,
|
||||
Thumbnail: accountUser.User.AvatarURL,
|
||||
Availability: accountUser.Availability,
|
||||
Metric: metric,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
return result[i].Metric["open"] > result[j].Metric["open"]
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) liveConversationMetricsForAssignee(ctx context.Context, accountID uint, assigneeID uint) (map[string]int64, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := func() *gorm.DB {
|
||||
return db.WithContext(ctx).Model(&model.Conversation{}).
|
||||
Where("account_id = ? AND assignee_id = ? AND status = ?", accountID, assigneeID, string(model.ConversationStatusOpen))
|
||||
}
|
||||
var openCount int64
|
||||
var unattendedCount int64
|
||||
if err := base().Count(&openCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := base().Where("first_reply_created_at IS NULL OR waiting_since IS NOT NULL").Count(&unattendedCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]int64{"open": openCount, "unattended": unattendedCount}, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) conversationSummary(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) {
|
||||
|
||||
@@ -105,6 +105,17 @@ type GroupedConversationMetric struct {
|
||||
UnassignedCount int64 `json:"unassigned"`
|
||||
}
|
||||
|
||||
// ReportAgentConversationMetric is the legacy reports/conversations agent payload.
|
||||
// Reference: V2::ReportBuilder#agent_metrics.
|
||||
type ReportAgentConversationMetric struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Availability string `json:"availability"`
|
||||
Metric map[string]int64 `json:"metric"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -423,8 +434,8 @@ type BotSummaryResponse struct {
|
||||
|
||||
// GetConversationsByType returns conversation metrics filtered by report type.
|
||||
// Reference: Chatwoot reports#conversations — type param is required
|
||||
func (s *AnalyticsService) GetConversationsByType(ctx context.Context, accountID uint, reportType string, since, until time.Time) (interface{}, error) {
|
||||
return s.conversationMetricsByType(ctx, accountID, reportType)
|
||||
func (s *AnalyticsService) GetConversationsByType(ctx context.Context, accountID uint, reportType string, page int) (interface{}, error) {
|
||||
return s.conversationMetricsByType(ctx, accountID, reportType, page)
|
||||
}
|
||||
|
||||
// GetConversationsSummary returns conversations summary report.
|
||||
|
||||
Reference in New Issue
Block a user