package service import ( "context" "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 } // 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 } // 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) }