feat(reports): filter grouped live metrics by team

This commit is contained in:
2026-06-07 01:01:27 +08:00
parent 64d5e2420e
commit 18f6403128
5 changed files with 69 additions and 22 deletions
+7 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 live reports error-shape parity checkpoint, prepared as `feat(reports): align live grouped errors`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align live grouped errors`.
- Latest documentation/tooling checkpoint: this tracker update records P3.47 live reports grouped error-shape parity plus the committed 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 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/*`.
- 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#set_group_scope` error shape for invalid `group_by`, returning raw `422 { error: "invalid group_by" }` instead of the local `{ success: false, error: ... }` envelope. Focused grouped live-report tests also cover the Chatwoot assignee grouping payload with nil assignee buckets. 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: 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.
- 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.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. |
| 0 | P3.45 report bot raw-count strategy parity | Implemented for reused v2 reports chart clients: GoChat now follows inspected Chatwoot `Reports::RawDataSource#reporting_event_count_scope`, `Reports::ReportMetricRegistry`, and v2 timeseries specs for bot metrics. `bot_handoffs_count` uses the `distinct_conversation` strategy and ignores nil conversation IDs, so duplicate handoff rows for the same conversation count once per bucket. `bot_resolutions_count` uses the `exclude_bot_handoffs` strategy and removes conversations that also have a bot handoff in the same scoped range before timeseries bucketing. Dimension scopes reuse the existing account/inbox/agent/team/label filters. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for aggregate-value endpoints beyond the current timeseries route, data-source scope behavior that differs for team/label dimensions, or future `ReportMetricRegistry` count strategies. | Focused Analytics service bot-timeseries tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -286,6 +287,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `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. |
| `docs: consolidate parity execution tracker` | Documentation-only checkpoint requested before continuing implementation. It records the already-committed `aa159f6 feat(reports): align bot count strategies` implementation baseline, adds a one-page tracking dashboard, and makes the next-turn execution rule explicit: Phase 2/3 drift audit first unless the full B12 live stack is available. | `git diff --check`. No Go code, migrations, or route artifacts changed. | Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from this tracker; `.hermes/plans/*` remain source notes only. |
@@ -2551,3 +2553,4 @@ Verification milestone gates:
- 2026-06-07: Documentation checkpoint prepared as `docs: consolidate parity execution tracker`; confirms `aa159f6 feat(reports): align bot count strategies` is already committed before new implementation work, adds the one-page tracking dashboard, keeps `.hermes/plans/*` as source notes only, and makes Phase 2/3 drift audit the next default action unless the full B12 live stack is available. Verification for this docs-only checkpoint: `git diff --check`.
- 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.
+21 -9
View File
@@ -31,14 +31,9 @@ func (h *LiveReportHandler) ConversationMetrics(c *gin.Context) {
return
}
teamID := uint(0)
if teamIDStr := c.Query("team_id"); teamIDStr != "" {
parsed, err := strconv.ParseUint(teamIDStr, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team_id")
return
}
teamID = uint(parsed)
teamID, ok := parseLiveReportTeamID(c)
if !ok {
return
}
result, err := h.svc.GetConversationMetricsForTeam(c.Request.Context(), accountID, teamID)
@@ -68,8 +63,12 @@ func (h *LiveReportHandler) GroupedConversationMetrics(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid group_by"})
return
}
teamID, ok := parseLiveReportTeamID(c)
if !ok {
return
}
result, err := h.svc.GetGroupedConversationMetrics(c.Request.Context(), accountID, groupBy)
result, err := h.svc.GetGroupedConversationMetricsForTeam(c.Request.Context(), accountID, groupBy, teamID)
if err != nil {
applogger.L().Errorf("Grouped conversation metrics: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get grouped conversation metrics")
@@ -78,3 +77,16 @@ func (h *LiveReportHandler) GroupedConversationMetrics(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
func parseLiveReportTeamID(c *gin.Context) (uint, bool) {
teamIDStr := c.Query("team_id")
if teamIDStr == "" {
return 0, true
}
parsed, err := strconv.ParseUint(teamIDStr, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team_id")
return 0, false
}
return uint(parsed), true
}
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
@@ -25,7 +26,7 @@ type LiveReportHandlerTestSuite struct {
func (s *LiveReportHandlerTestSuite) SetupSuite() {
s.db, _ = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
s.db.AutoMigrate(&model.Account{}, &model.Conversation{}, &model.ReportingEventsRollup{})
s.db.AutoMigrate(&model.Account{}, &model.Team{}, &model.Conversation{}, &model.ReportingEventsRollup{})
anSvc := service.NewAnalyticsService(
repository.NewReportingEventRepo(s.db),
@@ -42,6 +43,7 @@ func (s *LiveReportHandlerTestSuite) SetupSuite() {
func (s *LiveReportHandlerTestSuite) SetupTest() {
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM teams")
}
func TestLiveReportHandlerTestSuite(t *testing.T) {
@@ -106,3 +108,27 @@ func (s *LiveReportHandlerTestSuite) TestGroupedConversationMetrics_ByAssignee()
s.Equal(float64(1), body[1]["unattended"])
s.Equal(float64(0), body[1]["unassigned"])
}
func (s *LiveReportHandlerTestSuite) TestGroupedConversationMetrics_FiltersByTeamID() {
team := model.Team{AccountID: 1, Name: "Support"}
otherTeam := model.Team{AccountID: 1, Name: "Other"}
s.Require().NoError(s.db.Create(&team).Error)
s.Require().NoError(s.db.Create(&otherTeam).Error)
agentID := uint(7)
teamConversation := model.Conversation{AccountID: 1, TeamID: &team.ID, AssigneeID: &agentID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
otherConversation := model.Conversation{AccountID: 1, TeamID: &otherTeam.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(&teamConversation).Error)
s.Require().NoError(s.db.Create(&otherConversation).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/live_reports/grouped_conversation_metrics?group_by=assignee_id&team_id="+strconv.FormatUint(uint64(team.ID), 10), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var body []map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Len(body, 1)
s.Equal(float64(agentID), body[0]["assignee_id"])
s.Equal(float64(1), body[0]["open"])
s.Equal(float64(1), body[0]["unattended"])
s.Equal(float64(0), body[0]["unassigned"])
}
+9 -7
View File
@@ -375,7 +375,7 @@ func parseReportDimensionID(raw string) uint {
return uint(parsed)
}
func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) {
func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, accountID uint, groupBy string, teamID uint) ([]map[string]interface{}, error) {
if groupBy != "team_id" && groupBy != "assignee_id" {
return nil, errors.New("invalid group_by")
}
@@ -389,11 +389,13 @@ func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, a
}
load := func(where string) (map[uint]int64, error) {
var rows []row
if err := db.WithContext(ctx).Model(&model.Conversation{}).
q := db.WithContext(ctx).Model(&model.Conversation{}).
Select(groupBy+" AS group_id, COUNT(*) AS count").
Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen)).
Group(groupBy).
Scan(&rows).Error; err != nil {
Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen))
if teamID > 0 {
q = q.Where("team_id = ?", teamID)
}
if err := q.Group(groupBy).Scan(&rows).Error; err != nil {
return nil, err
}
counts := map[uint]int64{}
@@ -591,9 +593,9 @@ func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accoun
case "account":
return s.liveConversationMetrics(ctx, accountID, 0)
case "agent":
return s.groupedLiveConversationMetrics(ctx, accountID, "assignee_id")
return s.groupedLiveConversationMetrics(ctx, accountID, "assignee_id", 0)
case "team":
return s.groupedLiveConversationMetrics(ctx, accountID, "team_id")
return s.groupedLiveConversationMetrics(ctx, accountID, "team_id", 0)
default:
return s.liveConversationMetrics(ctx, accountID, 0)
}
+5 -1
View File
@@ -254,7 +254,11 @@ func (s *AnalyticsService) GetConversationMetricsForTeam(ctx context.Context, ac
// GetGroupedConversationMetrics returns conversation metrics grouped by team_id or assignee_id.
// Reference: Chatwoot live_reports#grouped_conversation_metrics
func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) {
return s.groupedLiveConversationMetrics(ctx, accountID, groupBy)
return s.GetGroupedConversationMetricsForTeam(ctx, accountID, groupBy, 0)
}
func (s *AnalyticsService) GetGroupedConversationMetricsForTeam(ctx context.Context, accountID uint, groupBy string, teamID uint) ([]map[string]interface{}, error) {
return s.groupedLiveConversationMetrics(ctx, accountID, groupBy, teamID)
}
// GetReportSummary returns the Chatwoot v2 report summary with previous-period data.