package service import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // AnalyticsService implements business logic for reporting/analytics. // Reference: Chatwoot app/controllers/api/v1/reports_controller.rb type AnalyticsService struct { eventRepo *repository.ReportingEventRepo rollupRepo *repository.ReportingEventsRollupRepo db *gorm.DB rollups *ReportingRollupService worker *worker.WorkerPool } func NewAnalyticsService( eventRepo *repository.ReportingEventRepo, rollupRepo *repository.ReportingEventsRollupRepo, ) *AnalyticsService { var db *gorm.DB if rollupRepo != nil { db = rollupRepo.DB() } if db == nil && eventRepo != nil { db = eventRepo.DB() } return &AnalyticsService{ eventRepo: eventRepo, rollupRepo: rollupRepo, db: db, rollups: NewReportingRollupService(eventRepo, rollupRepo), } } func (s *AnalyticsService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterReportingRollupJobs(wp, s) } // --- Response DTOs --- // MetricSummary holds aggregated metric data. type MetricSummary struct { Metric string `json:"metric"` Count int64 `json:"count"` AverageValue float64 `json:"average_value"` AverageBusinessHours float64 `json:"average_business_hours"` } // SummaryResponse is the top-level reports/summary response. type SummaryResponse struct { Metrics []MetricSummary `json:"metrics"` } // DimensionMetrics holds metrics grouped by a dimension (agent/inbox/team). type DimensionMetrics struct { DimensionID uint `json:"dimension_id"` DimensionType string `json:"dimension_type"` Metrics []MetricSummary `json:"metrics"` } // ConversationTrafficPoint holds a single time-series point. type ConversationTrafficPoint struct { Date time.Time `json:"date"` Count int64 `json:"count"` } // ConversationMetrics holds real-time conversation counts. // Reference: Chatwoot live_reports#conversation_metrics — { open, unattended, unassigned, pending } type ConversationMetrics struct { OpenCount int64 `json:"open"` UnattendedCount int64 `json:"unattended"` UnassignedCount int64 `json:"unassigned"` 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 { GroupID uint `json:"group_id"` OpenCount int64 `json:"open"` UnattendedCount int64 `json:"unattended"` 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"` } // InboxLabelMatrixFilter mirrors V2::Reports::InboxLabelMatrixBuilder params. type InboxLabelMatrixFilter struct { Since time.Time Until time.Time InboxIDs []uint LabelIDs []uint } type ReportDrilldownParams struct { Metric, DimensionType, GroupBy string DimensionID uint Since, Until, BucketTimestamp time.Time TimezoneOffset float64 BusinessHours bool Page, PerPage int } type ReportDrilldownResult struct { Meta map[string]any `json:"meta"` Payload []map[string]any `json:"payload"` } var reportDrilldownMetrics = map[string]string{ "conversations_count": "", "incoming_messages_count": "", "outgoing_messages_count": "", "avg_first_response_time": "first_response", "avg_resolution_time": "conversation_resolved", "reply_time": "reply_time", "resolutions_count": "conversation_resolved", "bot_resolutions_count": "conversation_bot_resolved", "bot_handoffs_count": "conversation_bot_handoff", } // 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 { return nil, err } rollups, err := s.rollupRepo.FindByAccountAndDateRange(ctx, accountID, since, until) if err != nil { applogger.L().Errorf("GetSummary rollup query: %v", err) return nil, err } // Aggregate by metric metricMap := map[string]*MetricSummary{} for _, r := range rollups { key := string(r.Metric) if m, ok := metricMap[key]; ok { m.Count += r.Count m.AverageValue += r.SumValue m.AverageBusinessHours += r.SumValueBusinessHours } else { metricMap[key] = &MetricSummary{ Metric: key, Count: r.Count, AverageValue: r.SumValue, AverageBusinessHours: r.SumValueBusinessHours, } } } // Compute averages metrics := make([]MetricSummary, 0, len(metricMap)) for _, m := range metricMap { if m.Count > 0 { m.AverageValue = m.AverageValue / float64(m.Count) m.AverageBusinessHours = m.AverageBusinessHours / float64(m.Count) } metrics = append(metrics, *m) } return &SummaryResponse{Metrics: metrics}, nil } // GetDrilldown returns the raw records behind one chart bucket using the exact // meta/payload envelope consumed by Chatwoot's report drilldown drawer. func (s *AnalyticsService) GetDrilldown(ctx context.Context, accountID uint, params ReportDrilldownParams) (*ReportDrilldownResult, error) { rawEvent, supported := reportDrilldownMetrics[params.Metric] if !supported { return nil, fmt.Errorf("unsupported metric") } if params.Page < 1 { params.Page = 1 } if params.PerPage <= 0 { params.PerPage = 25 } if params.PerPage > 100 { params.PerPage = 100 } if params.GroupBy == "" { params.GroupBy = "day" } if err := s.validateReportDimension(ctx, accountID, params); err != nil { return nil, err } bucketEnd := reportBucketEnd(params.BucketTimestamp, params.GroupBy, params.TimezoneOffset) bucketStart := params.BucketTimestamp if bucketStart.Before(params.Since) { bucketStart = params.Since } if bucketEnd.After(params.Until) { bucketEnd = params.Until } if !bucketEnd.After(bucketStart) { return nil, fmt.Errorf("invalid bucket range") } recordType := "conversation" var payload []map[string]any var total, conversationCount int64 if params.Metric == "incoming_messages_count" || params.Metric == "outgoing_messages_count" { recordType = "message" messageType := "incoming" if params.Metric == "outgoing_messages_count" { messageType = "outgoing" } messageScope := func() *gorm.DB { return s.reportMessageDimensionQuery(ctx, accountID, params).Where("messages.created_at >= ? AND messages.created_at < ? AND messages.message_type = ?", bucketStart, bucketEnd, messageType) } if err := messageScope().Count(&total).Error; err != nil { return nil, err } if err := messageScope().Distinct("messages.conversation_id").Count(&conversationCount).Error; err != nil { return nil, err } var messages []model.Message if err := messageScope().Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Order("messages.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&messages).Error; err != nil { return nil, err } payload = make([]map[string]any, 0, len(messages)) for i := range messages { payload = append(payload, s.reportMessageRecord(ctx, &messages[i], nil, nil)) } } else if params.Metric == "conversations_count" { conversationScope := func() *gorm.DB { return s.reportConversationDimensionQuery(ctx, accountID, params).Where("conversations.created_at >= ? AND conversations.created_at < ?", bucketStart, bucketEnd) } if err := conversationScope().Count(&total).Error; err != nil { return nil, err } conversationCount = total var conversations []model.Conversation if err := conversationScope().Preload("Contact").Preload("Inbox").Preload("Assignee").Order("conversations.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&conversations).Error; err != nil { return nil, err } payload = make([]map[string]any, 0, len(conversations)) for i := range conversations { payload = append(payload, s.reportConversationRecord(ctx, &conversations[i], nil, nil, "")) } } else { eventScope := func() *gorm.DB { q := s.reportEventDimensionQuery(ctx, accountID, params).Where("reporting_events.name = ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", rawEvent, bucketStart, bucketEnd) if params.Metric == "bot_resolutions_count" { handoffs := s.reportEventDimensionQuery(ctx, accountID, params).Select("reporting_events.conversation_id").Where("reporting_events.name = ? AND reporting_events.created_at BETWEEN ? AND ? AND reporting_events.conversation_id IS NOT NULL", "conversation_bot_handoff", params.Since, params.Until) q = q.Where("reporting_events.conversation_id NOT IN (?)", handoffs) } if params.Metric == "bot_handoffs_count" { distinctEvents := s.reportEventDimensionQuery(ctx, accountID, params).Select("MAX(reporting_events.id)").Where("reporting_events.name = ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ? AND reporting_events.conversation_id IS NOT NULL", rawEvent, bucketStart, bucketEnd).Group("reporting_events.conversation_id") q = q.Where("reporting_events.id IN (?)", distinctEvents) } return q } if err := eventScope().Count(&total).Error; err != nil { return nil, err } if err := eventScope().Distinct("reporting_events.conversation_id").Count(&conversationCount).Error; err != nil { return nil, err } var events []model.ReportingEvent if err := eventScope().Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Order("reporting_events.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&events).Error; err != nil { return nil, err } if params.Metric == "avg_first_response_time" || params.Metric == "reply_time" { recordType = "message" } payload = make([]map[string]any, 0, len(events)) for i := range events { payload = append(payload, s.reportEventRecord(ctx, &events[i], params)) } } return &ReportDrilldownResult{Meta: map[string]any{"metric": params.Metric, "record_type": recordType, "bucket": map[string]any{"since": bucketStart.Unix(), "until": bucketEnd.Unix()}, "current_page": params.Page, "per_page": params.PerPage, "total_count": total, "conversation_count": conversationCount}, Payload: payload}, nil } func (s *AnalyticsService) validateReportDimension(ctx context.Context, accountID uint, params ReportDrilldownParams) error { if params.DimensionType == "account" { return nil } var count int64 q := s.db.WithContext(ctx) switch params.DimensionType { case "inbox": q = q.Model(&model.Inbox{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID) case "agent": q = q.Model(&model.AccountUser{}).Where("user_id = ? AND account_id = ?", params.DimensionID, accountID) case "label": q = q.Model(&model.Tag{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID) case "team": q = q.Model(&model.Team{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID) default: return fmt.Errorf("unsupported dimension type") } if err := q.Count(&count).Error; err != nil { return err } if count == 0 { return gorm.ErrRecordNotFound } return nil } func (s *AnalyticsService) reportMessageDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB { q := s.db.WithContext(ctx).Model(&model.Message{}).Where("messages.account_id = ?", accountID) switch params.DimensionType { case "inbox": q = q.Where("messages.inbox_id = ?", params.DimensionID) case "agent": q = q.Where("messages.sender_id = ? AND messages.sender_type IN ?", params.DimensionID, []string{"User", "agent", "user"}) case "team": q = q.Joins("JOIN conversations drilldown_conversations ON drilldown_conversations.id = messages.conversation_id").Where("drilldown_conversations.team_id = ?", params.DimensionID) case "label": q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = messages.conversation_id").Where("drilldown_labels.tag_id = ?", params.DimensionID) } return q } func (s *AnalyticsService) reportConversationDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB { q := s.db.WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID) switch params.DimensionType { case "inbox": q = q.Where("conversations.inbox_id = ?", params.DimensionID) case "agent": q = q.Where("conversations.assignee_id = ?", params.DimensionID) case "team": q = q.Where("conversations.team_id = ?", params.DimensionID) case "label": q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = conversations.id").Where("drilldown_labels.tag_id = ?", params.DimensionID) } return q } func (s *AnalyticsService) reportEventDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB { q := s.db.WithContext(ctx).Model(&model.ReportingEvent{}).Where("reporting_events.account_id = ?", accountID) switch params.DimensionType { case "inbox": q = q.Where("reporting_events.inbox_id = ?", params.DimensionID) case "agent": q = q.Where("reporting_events.user_id = ?", params.DimensionID) case "team": q = q.Joins("JOIN conversations drilldown_conversations ON drilldown_conversations.id = reporting_events.conversation_id").Where("drilldown_conversations.team_id = ?", params.DimensionID) case "label": q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = reporting_events.conversation_id").Where("drilldown_labels.tag_id = ?", params.DimensionID) } return q } func (s *AnalyticsService) reportEventRecord(ctx context.Context, event *model.ReportingEvent, params ReportDrilldownParams) map[string]any { metricValue := event.Value if params.BusinessHours { metricValue = event.ValueInBusinessHours } occurredAt := event.CreatedAt if !event.EventEndTime.IsZero() { occurredAt = event.EventEndTime } if params.Metric == "avg_first_response_time" || params.Metric == "reply_time" { var message model.Message q := s.db.WithContext(ctx).Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Where("account_id = ? AND conversation_id = ? AND message_type IN ?", event.AccountID, event.ConversationID, []string{"outgoing", "template"}) if !event.EventEndTime.IsZero() { q = q.Where("created_at BETWEEN ? AND ?", event.EventEndTime.Add(-time.Second), event.EventEndTime.Add(time.Second)) } if err := q.Order("created_at DESC, id DESC").First(&message).Error; err == nil { return s.reportMessageRecord(ctx, &message, &metricValue, &occurredAt) } } return s.reportConversationRecord(ctx, event.Conversation, &metricValue, &occurredAt, event.Name) } func (s *AnalyticsService) reportMessageRecord(ctx context.Context, message *model.Message, metricValue *float64, occurredAt *time.Time) map[string]any { when := message.CreatedAt if occurredAt != nil { when = *occurredAt } return map[string]any{"record_type": "message", "conversation": s.reportConversationAttributes(ctx, message.Conversation), "message": map[string]any{"id": message.ID, "content": message.Content, "message_type": message.MessageType, "sender_name": s.reportSenderName(ctx, message), "created_at": message.CreatedAt.Unix()}, "metric_value": metricValue, "occurred_at": when.Unix()} } func (s *AnalyticsService) reportConversationRecord(ctx context.Context, conversation *model.Conversation, metricValue *float64, occurredAt *time.Time, eventName string) map[string]any { when := int64(0) if conversation != nil { when = conversation.CreatedAt.Unix() } if occurredAt != nil { when = occurredAt.Unix() } record := map[string]any{"record_type": "conversation", "conversation": s.reportConversationAttributes(ctx, conversation), "message": nil, "metric_value": metricValue, "occurred_at": when} if eventName != "" { record["event_name"] = eventName } return record } func (s *AnalyticsService) reportConversationAttributes(ctx context.Context, conversation *model.Conversation) map[string]any { if conversation == nil || conversation.ID == 0 { return map[string]any{} } var last model.Message _ = s.db.WithContext(ctx).Where("conversation_id = ? AND message_type <> ?", conversation.ID, "activity").Order("created_at DESC, id DESC").First(&last).Error var lastPayload any if last.ID != 0 { lastPayload = map[string]any{"id": last.ID, "content": last.Content, "message_type": last.MessageType, "sender_name": s.reportSenderName(ctx, &last), "created_at": last.CreatedAt.Unix()} } assigneeName := "" if conversation.Assignee != nil { assigneeName = conversation.Assignee.Name } return map[string]any{"id": conversation.ID, "display_id": conversation.DisplayID, "contact_id": conversation.ContactID, "contact_name": conversation.Contact.Name, "inbox_id": conversation.InboxID, "inbox_name": conversation.Inbox.Name, "assignee_id": conversation.AssigneeID, "assignee_name": assigneeName, "status": conversation.Status, "created_at": conversation.CreatedAt.Unix(), "last_activity_at": reportInt64Value(conversation.LastActivityAt), "last_message": lastPayload} } func (s *AnalyticsService) reportSenderName(ctx context.Context, message *model.Message) any { if message.SenderID == nil { return nil } if message.SenderType == "Captain::Assistant" || message.SenderType == "CaptainAssistant" || message.SenderType == "captain_assistant" { var assistant model.CaptainAssistant if s.db.WithContext(ctx).First(&assistant, *message.SenderID).Error == nil { return assistant.Name } return nil } if message.SenderType == "Contact" || message.SenderType == "contact" { var contact model.Contact if s.db.WithContext(ctx).First(&contact, *message.SenderID).Error == nil { return contact.Name } } var user model.User if s.db.WithContext(ctx).First(&user, *message.SenderID).Error == nil { return user.Name } return nil } func reportBucketEnd(start time.Time, groupBy string, timezoneOffset float64) time.Time { location := time.FixedZone("report", int(timezoneOffset*3600)) start = start.In(location) switch groupBy { case "hour": return start.Add(time.Hour).UTC() case "week": return start.AddDate(0, 0, 7).UTC() case "month": return start.AddDate(0, 1, 0).UTC() case "year": return start.AddDate(1, 0, 0).UTC() default: return start.AddDate(0, 0, 1).UTC() } } func reportInt64Value(value *int64) int64 { if value == nil { return 0 } return *value } // GetAgentMetrics returns metrics grouped by agent dimension. func (s *AnalyticsService) GetAgentMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) { return s.getDimensionMetrics(ctx, accountID, model.DimensionAgent, since, until) } // GetInboxMetrics returns metrics grouped by inbox dimension. func (s *AnalyticsService) GetInboxMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) { return s.getDimensionMetrics(ctx, accountID, model.DimensionInbox, since, until) } // GetLabelMetrics returns metrics grouped by label dimension. // Note: Chatwoot uses labels as a special dimension; we approximate with account-level rollups. func (s *AnalyticsService) GetLabelMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) { return s.getDimensionMetrics(ctx, accountID, model.DimensionAccount, since, until) } // GetTeamMetrics returns metrics grouped by team dimension. func (s *AnalyticsService) GetTeamMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) { return s.getDimensionMetrics(ctx, accountID, model.DimensionTeam, since, until) } func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID uint, dimType model.DimensionType, since, until time.Time) ([]DimensionMetrics, error) { if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil { return nil, err } rollups, err := s.rollupRepo.AggregateSummary(ctx, accountID, dimType, since, until) if err != nil { applogger.L().Errorf("getDimensionMetrics query: %v", err) return nil, err } // Group by dimension_id grouped := map[uint]*DimensionMetrics{} for _, r := range rollups { if dm, ok := grouped[r.DimensionID]; ok { ms := MetricSummary{ Metric: string(r.Metric), Count: r.Count, AverageValue: r.SumValue, AverageBusinessHours: r.SumValueBusinessHours, } if r.Count > 0 { ms.AverageValue = r.SumValue / float64(r.Count) ms.AverageBusinessHours = r.SumValueBusinessHours / float64(r.Count) } dm.Metrics = append(dm.Metrics, ms) } else { ms := MetricSummary{ Metric: string(r.Metric), Count: r.Count, AverageValue: r.SumValue, AverageBusinessHours: r.SumValueBusinessHours, } if r.Count > 0 { ms.AverageValue = r.SumValue / float64(r.Count) ms.AverageBusinessHours = r.SumValueBusinessHours / float64(r.Count) } grouped[r.DimensionID] = &DimensionMetrics{ DimensionID: r.DimensionID, DimensionType: string(r.DimensionType), Metrics: []MetricSummary{ms}, } } } result := make([]DimensionMetrics, 0, len(grouped)) for _, dm := range grouped { result = append(result, *dm) } return result, nil } // GetConversationTraffic returns daily conversation count time-series. func (s *AnalyticsService) GetConversationTraffic(ctx context.Context, accountID uint, since, until time.Time) ([]ConversationTrafficPoint, error) { if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil { return nil, err } rollups, err := s.rollupRepo.FindByMetric(ctx, accountID, model.MetricResolutionsCount, since, until) if err != nil { applogger.L().Errorf("GetConversationTraffic query: %v", err) return nil, err } points := make([]ConversationTrafficPoint, 0, len(rollups)) for _, r := range rollups { points = append(points, ConversationTrafficPoint{ Date: r.Date, Count: r.Count, }) } return points, nil } // GetConversationMetrics returns real-time conversation counts. // GetConversationMetrics returns real-time conversation counts. // Reference: Chatwoot live_reports#conversation_metrics — open, unattended, unassigned, pending func (s *AnalyticsService) GetConversationMetrics(ctx context.Context, accountID uint) (*ConversationMetrics, error) { return s.GetConversationMetricsForTeam(ctx, accountID, 0) } func (s *AnalyticsService) GetConversationMetricsForTeam(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) { return s.liveConversationMetrics(ctx, accountID, teamID) } // 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.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. 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 { applogger.L().Errorf("RecordEvent: %v", err) return err } if s.rollups != nil { if err := s.rollups.RollupEvent(ctx, event); err != nil { applogger.L().Errorf("RecordEvent rollup: %v", err) } } return nil } // RollupDaily aggregates events into daily rollups for a given account and date. func (s *AnalyticsService) RollupDaily(ctx context.Context, accountID uint, date time.Time) error { startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) endOfDay := startOfDay.Add(24 * time.Hour) // Aggregate events by each metric and dimension type dimensions := []model.DimensionType{model.DimensionAccount, model.DimensionAgent, model.DimensionInbox, model.DimensionTeam} metricNames := []string{ model.MetricNameFirstResponse, model.MetricNameReplyTime, model.MetricNameResolutionTime, model.MetricNameBotResolutionsCount, model.MetricNameBotHandoffsCount, } for _, dimType := range dimensions { for _, metricName := range metricNames { events, err := s.eventRepo.FindByMetric(ctx, accountID, metricName, startOfDay, endOfDay) if err != nil { applogger.L().Errorf("RollupDaily FindByMetric(%s, %s): %v", dimType, metricName, err) continue } if len(events) == 0 { continue } // Determine the rollup metric mapping rollupMetric := mapEventNameToRollupMetric(metricName) if rollupMetric == "" { continue } // Group events by dimension_id based on dimension type grouped := s.groupEventsByDimension(events, dimType) for dimID, evts := range grouped { var sumValue, sumBH float64 for _, e := range evts { sumValue += e.Value sumBH += e.ValueInBusinessHours } rollup := &model.ReportingEventsRollup{ AccountID: accountID, Date: startOfDay, DimensionType: dimType, DimensionID: dimID, Metric: rollupMetric, Count: int64(len(evts)), SumValue: sumValue, SumValueBusinessHours: sumBH, } if err := s.rollupRepo.Create(ctx, rollup); err != nil { applogger.L().Errorf("RollupDaily create rollup: %v", err) } } } } return nil } func mapEventNameToRollupMetric(name string) model.RollupMetric { switch name { case model.MetricNameFirstResponse: return model.MetricFirstResponse case model.MetricNameReplyTime: return model.MetricReplyTime case model.MetricNameResolutionTime: return model.MetricResolutionTime case model.MetricNameBotResolutionsCount: return model.MetricBotResolutions case model.MetricNameBotHandoffsCount: return model.MetricBotHandoffs default: return "" } } func (s *AnalyticsService) groupEventsByDimension(events []model.ReportingEvent, dimType model.DimensionType) map[uint][]model.ReportingEvent { grouped := map[uint][]model.ReportingEvent{} for _, e := range events { var dimID uint switch dimType { case model.DimensionAccount: dimID = e.AccountID case model.DimensionAgent: if e.UserID != nil { dimID = *e.UserID } else { dimID = 0 } case model.DimensionInbox: if e.InboxID != nil { dimID = *e.InboxID } else { dimID = 0 } case model.DimensionTeam: dimID = e.AccountID // team is approximated as account for now } grouped[dimID] = append(grouped[dimID], e) } return grouped } // GetBotSummary returns bot-level summary metrics. // Reference: Chatwoot reports#bot_summary 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, reportType, id) if err != nil { return nil, err } current.Previous = previous return current, nil } // BotSummaryResponse holds bot summary metrics. type BotSummaryResponse struct { BotResolutionsCount int64 `json:"bot_resolutions_count"` BotHandoffsCount int64 `json:"bot_handoffs_count"` Previous *BotSummaryResponse `json:"previous,omitempty"` } // 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, page int) (interface{}, error) { return s.conversationMetricsByType(ctx, accountID, reportType, page) } // GetConversationsSummary returns conversations summary report. // Reference: Chatwoot reports#conversations_summary func (s *AnalyticsService) GetConversationsSummary(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { return s.conversationSummary(ctx, accountID, since, until) } // GetBotMetrics returns bot metrics. // Reference: Chatwoot reports#bot_metrics — V2::Reports::BotMetricsBuilder func (s *AnalyticsService) GetBotMetrics(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { return s.botMetrics(ctx, accountID, since, until) } // GetInboxLabelMatrix returns inbox-label matrix data. // Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint, filter InboxLabelMatrixFilter) (interface{}, error) { return s.inboxLabelMatrix(ctx, accountID, filter) } // GetFirstResponseTimeDistribution returns first response time distribution. // Reference: Chatwoot reports#first_response_time_distribution func (s *AnalyticsService) GetFirstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { return s.firstResponseTimeDistribution(ctx, accountID, since, until) } // GetOutgoingMessagesCount returns outgoing message count metrics. // Reference: Chatwoot reports#outgoing_messages_count func (s *AnalyticsService) GetOutgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) { return s.outgoingMessagesCount(ctx, accountID, since, until, "") } func (s *AnalyticsService) GetOutgoingMessagesCountGrouped(ctx context.Context, accountID uint, since, until time.Time, groupBy string) (interface{}, error) { return s.outgoingMessagesCount(ctx, accountID, since, until, groupBy) }