feat(reports): align summary aggregates
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user