From ea56eb16c2fd080823c8fd2e079ed7c568164c55 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 00:44:48 +0800 Subject: [PATCH] feat(reports): align summary aggregates --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 11 +- internal/handler/api/v1/analytics_handler.go | 27 ++- .../handler/api/v1/analytics_handler_test.go | 18 +- internal/service/analytics_p513_test.go | 42 ++++- internal/service/analytics_query_helpers.go | 161 +++++++++++++++--- internal/service/analytics_service.go | 35 +++- 6 files changed, 261 insertions(+), 33 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 1bde1f39..26aa5ddb 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 documentation consolidation checkpoint, prepared as `docs: consolidate parity execution tracker`. -- Latest implementation checkpoint: committed baseline `aa159f6 feat(reports): align bot count strategies`. -- Latest documentation/tooling checkpoint: this tracker update records the committed P3.45 report bot raw-count strategy parity plus the 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 report summary aggregate parity checkpoint, prepared as `feat(reports): align summary aggregates`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align summary aggregates`. +- Latest documentation/tooling checkpoint: this tracker update records P3.46 report summary aggregate parity plus the committed 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: report bot metric count strategies from `reference/chatwoot/app/services/reports/raw_data_source.rb`, `ReportMetricRegistry`, and `spec/builders/v2/reports/timeseries/report_builder_spec.rb` are aligned for v2 report timeseries. `bot_handoffs_count` now counts distinct non-null `conversation_id` values per bucket, and `bot_resolutions_count` now excludes conversations that also have a bot handoff in the same scoped range before bucketing. 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: API v2 report summary responses now match inspected Chatwoot `Api::V2::Accounts::ReportsController#summary/#bot_summary`, `V2::Reports::Conversations::MetricBuilder`, `Reports::RawDataSource#aggregate`, and `ReportMetricRegistry` behavior. `/reports/summary` returns raw Chatwoot metric keys plus `previous`, scoped by current/previous ranges, report type/id, and business-hours average selection. `/reports/bot_summary` uses aggregate raw count strategies: `bot_handoffs_count` counts distinct non-null conversations, while `bot_resolutions_count` counts event rows after excluding conversations with bot handoffs in the same scoped range. 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.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. | | 0 | P3.44 reporting live additive rollup parity | Implemented for live reporting-event freshness: GoChat now follows inspected Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService#upsert_rollups`, and `ReportingEvents::EventMetricRegistry` behavior when `AnalyticsService.RecordEvent` persists a raw event. Valid account `reporting_timezone` gates the live write, event `created_at` determines the local rollup date, account/agent/inbox dimensions are emitted only when present, known event metrics expand to Chatwoot rollup metrics, unknown raw events emit no rollup rows, and conflicts add incoming `count`, `sum_value`, and `sum_value_business_hours` to the existing unique row. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for dispatcher/listener event creation paths that bypass `AnalyticsService.RecordEvent`, Sidekiq retry semantics beyond swallowed live-rollup errors, or report metric registry additions beyond the inspected reference event registry. | Focused Reporting live rollup/repository tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.43 reporting rollup/backfill timezone parity | Implemented for reused report data freshness paths: GoChat now follows inspected Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, reporting-event service specs, and `Account#reporting_timezone` validation behavior by persisting/serializing `reporting_timezone`, treating blank or invalid values as a rollup-write skip, converting local report dates into UTC windows through the account reporting timezone, querying reporting events by `created_at >= start AND created_at < end`, skipping nil agent/inbox dimensions instead of falling back to the account id, zeroing count-metric sum fields, and counting distinct `conversation_bot_handoff` conversations per account/agent/inbox dimension. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActiveSupport timezone-name aliases beyond IANA names, data-source rollup read feature flags, or report metric registry drift beyond the inspected rollup/backfill contract. | Focused Reporting rollup/backfill/service tests, focused Analytics/Account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -284,6 +285,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `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. | | `feat(reports): align bot count strategies` | Advances P3.45 report bot raw-count strategy parity by matching Chatwoot `Reports::RawDataSource#reporting_event_count_scope`, `Reports::ReportMetricRegistry`, and v2 report timeseries specs. GoChat now applies `distinct_conversation` for `bot_handoffs_count`, ignores nil handoff conversation IDs, and applies `exclude_bot_handoffs` for `bot_resolutions_count` before bucketing so conversations that both resolve and hand off are not double counted. | `go test ./internal/service -run 'Analytics.*Timeseries\|BotTimeseries\|Reporting.*Rollup\|Backfill' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.45 moves to Review for current bot metric raw-count evidence; continue Phase 2/3 drift audit for aggregate-value endpoints, report data-source/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): add live rollup upserts` | Advances P3.44 reporting live additive rollup parity by matching Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService`, and `ReportingEvents::EventMetricRegistry`. GoChat now applies rollups immediately after `AnalyticsService.RecordEvent` persists the raw reporting event, computes the local rollup date from the account `reporting_timezone`, emits only known event-registry metrics, and additively upserts unique rollup rows so repeated live events increment existing `count`, `sum_value`, and `sum_value_business_hours`. | `go test ./internal/service ./internal/repository -run 'Reporting.*Rollup\|Backfill\|Analytics\|ReportingEventsRollup' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.44 moves to Review for current live-rollup evidence; continue Phase 2/3 drift audit for event-listener paths bypassing `AnalyticsService.RecordEvent`, report data-source/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2545,3 +2547,4 @@ Verification milestone gates: - 2026-06-06: P3.44 reporting live additive rollup checkpoint prepared as `feat(reports): add live rollup upserts`; audited Chatwoot reporting event listener safe-rollup path, rollup service additive `upsert_all`, and event metric registry. GoChat now applies live rollups from `AnalyticsService.RecordEvent` after raw event persistence, uses account reporting timezone for the event-created local date, ignores unknown raw metrics, and additively upserts existing rollup rows instead of waiting for lazy daily recompute. Focused reporting live rollup/repository tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.45 report bot raw-count strategy checkpoint prepared as `feat(reports): align bot count strategies`; audited Chatwoot `Reports::RawDataSource`, `Reports::ReportMetricRegistry`, and v2 timeseries bot-resolution specs. GoChat `bot_handoffs_count` timeseries now counts distinct non-null conversations per bucket, while `bot_resolutions_count` excludes conversations that also handed off in the scoped range before bucketing. Focused Analytics timeseries tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change. - 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. diff --git a/internal/handler/api/v1/analytics_handler.go b/internal/handler/api/v1/analytics_handler.go index 241b5d9e..8b830fbf 100644 --- a/internal/handler/api/v1/analytics_handler.go +++ b/internal/handler/api/v1/analytics_handler.go @@ -121,6 +121,18 @@ func parseReportTimezoneOffset(c *gin.Context) (float64, bool) { return 0, true } +func parseOptionalReportID(c *gin.Context) (uint, bool) { + if rawID := c.Query("id"); rawID != "" { + parsed, err := strconv.ParseUint(rawID, 10, 64) + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") + return 0, false + } + return uint(parsed), true + } + return 0, true +} + func parseConversationTrafficRange(c *gin.Context) (time.Time, time.Time, float64, bool) { timezoneOffset, ok := parseReportTimezoneOffset(c) if !ok { @@ -210,8 +222,15 @@ func (h *AnalyticsHandler) Summary(c *gin.Context) { if !ok { return } + if _, ok := parseReportTimezoneOffset(c); !ok { + return + } + id, ok := parseOptionalReportID(c) + if !ok { + return + } - result, err := h.svc.GetSummary(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetReportSummary(c.Request.Context(), accountID, since, until, c.DefaultQuery("type", "account"), id, parseReportBusinessHours(c)) if err != nil { applogger.L().Errorf("Summary report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate summary report") @@ -370,8 +389,12 @@ func (h *AnalyticsHandler) BotSummary(c *gin.Context) { if !ok { return } + id, ok := parseOptionalReportID(c) + if !ok { + return + } - result, err := h.svc.GetBotSummary(c.Request.Context(), accountID, since, until) + result, err := h.svc.GetBotSummary(c.Request.Context(), accountID, since, until, c.DefaultQuery("type", "account"), id) if err != nil { applogger.L().Errorf("Bot summary report: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate bot summary") diff --git a/internal/handler/api/v1/analytics_handler_test.go b/internal/handler/api/v1/analytics_handler_test.go index 78ca6320..f44dc1f0 100644 --- a/internal/handler/api/v1/analytics_handler_test.go +++ b/internal/handler/api/v1/analytics_handler_test.go @@ -145,16 +145,24 @@ func (s *AnalyticsHandlerTestSuite) TestSummary_EmptyData() { var body map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &body)) s.NotContains(body, "success") - s.NotNil(body["metrics"]) + s.Equal(float64(0), body["conversations_count"]) + s.Equal(float64(0), body["incoming_messages_count"]) + s.Equal(float64(0), body["outgoing_messages_count"]) + s.NotNil(body["previous"]) } func (s *AnalyticsHandlerTestSuite) TestSummary_WithData() { - // Seed a reporting event + conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} + s.Require().NoError(s.db.Create(&conv).Error) + outgoing := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeOutgoing), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}} + s.Require().NoError(s.db.Create(&outgoing).Error) ev := model.ReportingEvent{ + Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}, AccountID: s.accountID, Name: "first_response", Value: 120.5, ValueInBusinessHours: 60.0, + ConversationID: &conv.ID, EventStartTime: parseTime("2025-01-15T10:00:00Z"), EventEndTime: parseTime("2025-01-15T10:02:00Z"), } @@ -165,6 +173,12 @@ func (s *AnalyticsHandlerTestSuite) TestSummary_WithData() { "/api/v1/accounts/1/reports/summary?since=2025-01-01T00:00:00Z&until=2025-02-01T00:00:00Z", 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.Equal(float64(1), body["conversations_count"]) + s.Equal(float64(1), body["outgoing_messages_count"]) + s.Equal(120.5, body["avg_first_response_time"]) + s.Contains(body, "previous") } func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesWithData() { diff --git a/internal/service/analytics_p513_test.go b/internal/service/analytics_p513_test.go index 7da4d328..d1379ad0 100644 --- a/internal/service/analytics_p513_test.go +++ b/internal/service/analytics_p513_test.go @@ -119,7 +119,7 @@ func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing. assert.Equal(t, int64(1), summaryMap["resolutions_count"]) assert.Equal(t, 1800.0, summaryMap["avg_first_response_time"]) - botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until) + botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until, "account", 0) require.NoError(t, err) assert.Equal(t, int64(1), botSummary.BotResolutionsCount) assert.Equal(t, int64(0), botSummary.BotHandoffsCount) @@ -241,3 +241,43 @@ func TestAnalyticsBotTimeseriesUsesRawCountStrategies(t *testing.T) { require.Len(t, handoffPoints, 1) assert.Equal(t, float64(2), handoffPoints[0].Value) } + +func TestAnalyticsBotSummaryUsesAggregateRawCountStrategies(t *testing.T) { + db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) + since := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) + until := since.Add(24 * time.Hour) + resolvedOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + doubleCounted := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + handoffOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} + require.NoError(t, db.Create(resolvedOnly).Error) + require.NoError(t, db.Create(doubleCounted).Error) + require.NoError(t, db.Create(handoffOnly).Error) + + seed := func(name string, conversationID *uint, minute int) { + require.NoError(t, db.Create(&model.ReportingEvent{ + Base: model.Base{CreatedAt: since.Add(time.Duration(minute) * time.Minute)}, + AccountID: account.ID, + Name: name, + ConversationID: conversationID, + InboxID: &inbox.ID, + UserID: &user.ID, + EventStartTime: since, + EventEndTime: since.Add(time.Duration(minute) * time.Minute), + }).Error) + } + seed("conversation_bot_resolved", &resolvedOnly.ID, 10) + seed("conversation_bot_resolved", &resolvedOnly.ID, 11) + seed("conversation_bot_resolved", &doubleCounted.ID, 20) + seed("conversation_bot_resolved", nil, 21) + seed("conversation_bot_handoff", &doubleCounted.ID, 30) + seed("conversation_bot_handoff", &handoffOnly.ID, 40) + seed("conversation_bot_handoff", &handoffOnly.ID, 50) + seed("conversation_bot_handoff", nil, 60) + + summary, err := svc.GetBotSummary(context.Background(), account.ID, since, until, "account", 0) + require.NoError(t, err) + assert.Equal(t, int64(3), summary.BotResolutionsCount) + assert.Equal(t, int64(2), summary.BotHandoffsCount) + assert.NotNil(t, summary.Previous) + assert.Equal(t, int64(0), summary.Previous.BotResolutionsCount) +} diff --git a/internal/service/analytics_query_helpers.go b/internal/service/analytics_query_helpers.go index 63b21408..a8e6c60c 100644 --- a/internal/service/analytics_query_helpers.go +++ b/internal/service/analytics_query_helpers.go @@ -439,34 +439,153 @@ func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, a return result, nil } -func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) { - db, err := s.analyticsDB() +func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint) (*BotSummaryResponse, error) { + result := &BotSummaryResponse{} + resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, "exclude_bot_handoffs") if err != nil { return nil, err } - var handoffIDs []uint - if err := db.WithContext(ctx).Model(&model.ReportingEvent{}). - Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until). - Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil { - return nil, err - } - result := &BotSummaryResponse{} - handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}). - Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until) - if err := handoffQ.Distinct("conversation_id").Count(&result.BotHandoffsCount).Error; err != nil { - return nil, err - } - resolvedQ := db.WithContext(ctx).Model(&model.ReportingEvent{}). - Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, since, until) - if len(handoffIDs) > 0 { - resolvedQ = resolvedQ.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs) - } - if err := resolvedQ.Distinct("conversation_id").Count(&result.BotResolutionsCount).Error; err != nil { + handoffs, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, "distinct_conversation") + if err != nil { return nil, err } + result.BotResolutionsCount = resolutions + result.BotHandoffsCount = handoffs return result, nil } +func (s *AnalyticsService) reportSummaryCounts(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, businessHours bool) (*ReportSummaryResponse, error) { + conversations, err := s.aggregateConversationCount(ctx, accountID, since, until, reportType, id, false) + if err != nil { + return nil, err + } + incoming, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeIncoming) + if err != nil { + return nil, err + } + outgoing, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeOutgoing) + if err != nil { + return nil, err + } + resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, "") + if err != nil { + return nil, err + } + firstResponse, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameFirstResponse}, businessHours) + if err != nil { + return nil, err + } + resolutionTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, businessHours) + if err != nil { + return nil, err + } + replyTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameReplyTime}, businessHours) + if err != nil { + return nil, err + } + return &ReportSummaryResponse{ + ConversationsCount: conversations, + IncomingMessagesCount: incoming, + OutgoingMessagesCount: outgoing, + AvgFirstResponseTime: firstResponse, + AvgResolutionTime: resolutionTime, + ResolutionsCount: resolutions, + ReplyTime: replyTime, + }, nil +} + +func (s *AnalyticsService) aggregateConversationCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, resolved bool) (int64, error) { + db, err := s.analyticsDB() + if err != nil { + return 0, err + } + q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID) + if resolved { + q = q.Where("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until) + } else { + q = q.Where("created_at >= ? AND created_at < ?", since, until) + } + q = applyConversationDimension(q, reportType, id) + var count int64 + if err := q.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (s *AnalyticsService) aggregateMessageCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, messageType model.MessageType) (int64, error) { + db, err := s.analyticsDB() + if err != nil { + return 0, err + } + q := db.WithContext(ctx).Model(&model.Message{}).Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(messageType), since, until) + q = applyMessageDimension(q, reportType, id) + var count int64 + if err := q.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (s *AnalyticsService) aggregateEventAverage(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, businessHours bool) (float64, error) { + db, err := s.analyticsDB() + if err != nil { + return 0, err + } + valueColumn := "value" + if businessHours { + valueColumn = "value_in_business_hours" + } + var row struct { + Value float64 + Count int64 + } + q := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Select("COALESCE(SUM("+valueColumn+"), 0) AS value, COUNT(*) AS count"). + Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until) + q = applyEventDimension(q, reportType, id) + if err := q.Scan(&row).Error; err != nil { + return 0, err + } + if row.Count == 0 { + return 0, nil + } + return row.Value / float64(row.Count), nil +} + +func (s *AnalyticsService) aggregateEventCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, countStrategy string) (int64, error) { + db, err := s.analyticsDB() + if err != nil { + return 0, err + } + q := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until) + q = applyEventDimension(q, reportType, id) + if countStrategy == "exclude_bot_handoffs" { + var handoffIDs []uint + handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}). + Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until) + handoffQ = applyEventDimension(handoffQ, reportType, id) + if err := handoffQ.Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil { + return 0, err + } + if len(handoffIDs) > 0 { + q = q.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs) + } + } + var count int64 + if countStrategy == "distinct_conversation" { + if err := q.Where("conversation_id IS NOT NULL").Distinct("conversation_id").Count(&count).Error; err != nil { + return 0, err + } + return count, nil + } + if err := q.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string) (interface{}, error) { switch reportType { case "account": @@ -556,7 +675,7 @@ func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since return nil, err } } - summary, err := s.botSummaryCounts(ctx, accountID, since, until) + summary, err := s.botSummaryCounts(ctx, accountID, since, until, "account", 0) if err != nil { return nil, err } diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index aa4851e9..592ec1cb 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -82,6 +82,20 @@ type ConversationMetrics struct { PendingCount int64 `json:"pending"` } +// ReportSummaryResponse is the Chatwoot v2 reports/summary payload. +// Reference: V2::Reports::Conversations::MetricBuilder#summary plus +// Api::V2::Accounts::ReportsController#build_summary. +type ReportSummaryResponse struct { + ConversationsCount int64 `json:"conversations_count"` + IncomingMessagesCount int64 `json:"incoming_messages_count"` + OutgoingMessagesCount int64 `json:"outgoing_messages_count"` + AvgFirstResponseTime float64 `json:"avg_first_response_time"` + AvgResolutionTime float64 `json:"avg_resolution_time"` + ResolutionsCount int64 `json:"resolutions_count"` + ReplyTime float64 `json:"reply_time"` + Previous *ReportSummaryResponse `json:"previous,omitempty"` +} + // GroupedConversationMetric holds conversation metrics grouped by team_id or assignee_id. // Reference: Chatwoot live_reports#grouped_conversation_metrics type GroupedConversationMetric struct { @@ -243,6 +257,21 @@ func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, ac return s.groupedLiveConversationMetrics(ctx, accountID, groupBy) } +// GetReportSummary returns the Chatwoot v2 report summary with previous-period data. +func (s *AnalyticsService) GetReportSummary(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, businessHours bool) (*ReportSummaryResponse, error) { + current, err := s.reportSummaryCounts(ctx, accountID, since, until, reportType, id, businessHours) + if err != nil { + return nil, err + } + previousSince := since.Add(-(until.Sub(since))) + previous, err := s.reportSummaryCounts(ctx, accountID, previousSince, since, reportType, id, businessHours) + if err != nil { + return nil, err + } + current.Previous = previous + return current, nil +} + // RecordEvent records a new reporting event. func (s *AnalyticsService) RecordEvent(ctx context.Context, event *model.ReportingEvent) error { if err := s.eventRepo.Create(ctx, event); err != nil { @@ -367,13 +396,13 @@ func (s *AnalyticsService) groupEventsByDimension(events []model.ReportingEvent, // GetBotSummary returns bot-level summary metrics. // Reference: Chatwoot reports#bot_summary -func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) { - current, err := s.botSummaryCounts(ctx, accountID, since, until) +func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint) (*BotSummaryResponse, error) { + current, err := s.botSummaryCounts(ctx, accountID, since, until, reportType, id) if err != nil { return nil, err } previousSince := since.Add(-(until.Sub(since))) - previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since) + previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since, reportType, id) if err != nil { return nil, err }