package service import ( "context" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // 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 } func NewAnalyticsService( eventRepo *repository.ReportingEventRepo, rollupRepo *repository.ReportingEventsRollupRepo, ) *AnalyticsService { return &AnalyticsService{ eventRepo: eventRepo, rollupRepo: rollupRepo, } } // --- 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"` } // 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"` } // 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) { 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) { 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) { 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) { // TODO: implement real-time open/unattended/unassigned/pending counts from conversations table // Currently returns placeholder data until conversation queries are implemented return &ConversationMetrics{ OpenCount: 0, UnattendedCount: 0, UnassignedCount: 0, PendingCount: 0, }, nil } // 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) ([]GroupedConversationMetric, error) { // TODO: implement grouped conversation metrics from conversations table // Currently returns empty until group-by queries are implemented return []GroupedConversationMetric{}, 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 } 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) (*BotSummaryResponse, error) { // TODO: implement bot summary using V2::Reports::BotSummaryBuilder logic return &BotSummaryResponse{}, nil } // BotSummaryResponse holds bot summary metrics. type BotSummaryResponse struct { BotResolutions int64 `json:"bot_resolutions"` BotHandoffs int64 `json:"bot_handoffs"` SelfService int64 `json:"self_service"` HumanService int64 `json:"human_service"` } // 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, since, until time.Time) (interface{}, error) { // TODO: implement conversation metrics by type using V2::Reports::Conversations::ConversationMetricsService return gin.H{}, nil } // 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) { // TODO: implement conversations summary using V2::Reports::Conversations::SummaryBuilder return gin.H{}, nil } // 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) { // TODO: implement bot metrics return gin.H{}, nil } // GetInboxLabelMatrix returns inbox-label matrix data. // Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint) (interface{}, error) { // TODO: implement inbox label matrix return gin.H{}, nil } // 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) { // TODO: implement FRT distribution return gin.H{}, nil } // 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) { // TODO: implement outgoing messages count return gin.H{}, nil }