package service import ( "context" "fmt" "strconv" "time" "github.com/gochat/gochat/internal/model" "gorm.io/gorm" ) type reportCSVMetricSet struct { ConversationsCount int64 ResolvedCount int64 AvgResolution float64 AvgFirstResponse float64 AvgReply float64 } func (s *AnalyticsService) GetAgentReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { db, err := s.analyticsDB() if err != nil { return nil, err } metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "agent", businessHours) if err != nil { return nil, err } var users []model.User if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&users).Error; err != nil { return nil, err } rows := make([][]string, 0, len(users)) for _, user := range users { metric := metrics[user.ID] rows = append(rows, append([]string{user.Name}, readableReportMetrics(metric)...)) } return rows, nil } func (s *AnalyticsService) GetInboxReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { db, err := s.analyticsDB() if err != nil { return nil, err } metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "inbox", businessHours) if err != nil { return nil, err } var inboxes []model.Inbox if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&inboxes).Error; err != nil { return nil, err } rows := make([][]string, 0, len(inboxes)) for _, inbox := range inboxes { metric := metrics[inbox.ID] rows = append(rows, []string{ inbox.Name, inbox.ChannelType, strconv.FormatInt(metric.ConversationsCount, 10), formatReportDuration(metric.AvgFirstResponse), formatReportDuration(metric.AvgResolution), }) } return rows, nil } func (s *AnalyticsService) GetTeamReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { db, err := s.analyticsDB() if err != nil { return nil, err } metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "team", businessHours) if err != nil { return nil, err } var teams []model.Team if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&teams).Error; err != nil { return nil, err } rows := make([][]string, 0, len(teams)) for _, team := range teams { metric := metrics[team.ID] rows = append(rows, append([]string{team.Name}, readableReportMetrics(metric)...)) } return rows, nil } func (s *AnalyticsService) GetLabelReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { db, err := s.analyticsDB() if err != nil { return nil, err } metrics, err := s.reportMetricsByLabel(ctx, accountID, since, until, businessHours) if err != nil { return nil, err } var labels []model.Tag if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&labels).Error; err != nil { return nil, err } rows := make([][]string, 0, len(labels)) for _, label := range labels { metric := metrics[label.ID] rows = append(rows, []string{ label.Name, strconv.FormatInt(metric.ConversationsCount, 10), formatReportDuration(metric.AvgFirstResponse), formatReportDuration(metric.AvgResolution), formatReportDuration(metric.AvgReply), strconv.FormatInt(metric.ResolvedCount, 10), }) } return rows, nil } func (s *AnalyticsService) GetConversationsSummaryCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) { summary, err := s.conversationSummary(ctx, accountID, since, until) if err != nil { return nil, err } return [][]string{{ intString(summary["conversations_count"]), intString(summary["incoming_messages_count"]), intString(summary["outgoing_messages_count"]), formatReportDuration(floatValue(summary["avg_first_response_time"])), formatReportDuration(floatValue(summary["avg_resolution_time"])), intString(summary["resolutions_count"]), formatReportDuration(floatValue(summary["reply_time"])), }}, nil } func (s *AnalyticsService) GetConversationTrafficCSVRows(ctx context.Context, accountID uint, since, until time.Time, timezoneOffset float64) ([][]string, error) { db, err := s.analyticsDB() if err != nil { return nil, err } loc := time.FixedZone("report", int(timezoneOffset*3600)) startLocal := time.Date(since.In(loc).Year(), since.In(loc).Month(), since.In(loc).Day(), 0, 0, 0, 0, loc) endLocal := time.Date(until.In(loc).Year(), until.In(loc).Month(), until.In(loc).Day(), 0, 0, 0, 0, loc) dates := make([]string, 0) for day := startLocal; day.Before(endLocal); day = day.AddDate(0, 0, 1) { dates = append(dates, day.Format("2006-01-02")) } counts := map[string]map[int]int64{} var conversations []model.Conversation if err := db.WithContext(ctx).Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, since, until).Find(&conversations).Error; err != nil { return nil, err } for _, conversation := range conversations { localTime := conversation.CreatedAt.In(loc) dateKey := localTime.Format("2006-01-02") if counts[dateKey] == nil { counts[dateKey] = map[int]int64{} } counts[dateKey][localTime.Hour()]++ } rows := make([][]string, 0, 25) rows = append(rows, append([]string{"Start of the hour"}, dates...)) for hour := 0; hour < 24; hour++ { row := []string{fmt.Sprintf("%02d:00", hour)} for _, date := range dates { row = append(row, strconv.FormatInt(counts[date][hour], 10)) } rows = append(rows, row) } return rows, nil } func (s *AnalyticsService) reportMetricsByDimension(ctx context.Context, accountID uint, since, until time.Time, dimension string, businessHours bool) (map[uint]reportCSVMetricSet, error) { db, err := s.analyticsDB() if err != nil { return nil, err } result := map[uint]reportCSVMetricSet{} field := map[string]string{"agent": "assignee_id", "inbox": "inbox_id", "team": "team_id"}[dimension] if field == "" { return result, nil } if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, false); err != nil { return nil, err } if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, true); err != nil { return nil, err } if err := s.loadDimensionEventAverages(ctx, db, result, accountID, since, until, dimension, businessHours); err != nil { return nil, err } return result, nil } func loadConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, field string, resolved bool) error { var rows []struct { ID uint Count int64 } query := db.WithContext(ctx).Model(&model.Conversation{}). Select(field+" AS id, COUNT(*) AS count"). Where("account_id = ? AND "+field+" IS NOT NULL", accountID). Group(field) if resolved { query = query.Where("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until) } else { query = query.Where("created_at >= ? AND created_at < ?", since, until) } if err := query.Scan(&rows).Error; err != nil { return err } for _, row := range rows { metric := result[row.ID] if resolved { metric.ResolvedCount = row.Count } else { metric.ConversationsCount = row.Count } result[row.ID] = metric } return nil } func (s *AnalyticsService) loadDimensionEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, dimension string, businessHours bool) error { valueColumn := "reporting_events.value" if businessHours { valueColumn = "reporting_events.value_in_business_hours" } field := map[string]string{"agent": "reporting_events.user_id", "inbox": "reporting_events.inbox_id", "team": "conversations.team_id"}[dimension] queryBase := func(names []string) *gorm.DB { q := db.WithContext(ctx).Model(&model.ReportingEvent{}). Select(field+" AS id, AVG("+valueColumn+") AS value"). Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until). Where(field + " IS NOT NULL"). Group(field) if dimension == "team" { q = q.Joins("INNER JOIN conversations ON conversations.id = reporting_events.conversation_id") } return q } load := func(names []string, apply func(*reportCSVMetricSet, float64)) error { var rows []struct { ID uint Value float64 } if err := queryBase(names).Scan(&rows).Error; err != nil { return err } for _, row := range rows { metric := result[row.ID] apply(&metric, row.Value) result[row.ID] = metric } return nil } if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil { return err } if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil { return err } return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value }) } func (s *AnalyticsService) reportMetricsByLabel(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) (map[uint]reportCSVMetricSet, error) { db, err := s.analyticsDB() if err != nil { return nil, err } result := map[uint]reportCSVMetricSet{} if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, false); err != nil { return nil, err } if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, true); err != nil { return nil, err } if err := loadLabelEventAverages(ctx, db, result, accountID, since, until, businessHours); err != nil { return nil, err } return result, nil } func loadLabelConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, resolved bool) error { var rows []struct { ID uint Count int64 } query := db.WithContext(ctx).Table("conversation_labels"). Select("conversation_labels.tag_id AS id, COUNT(*) AS count"). Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id"). Where("conversation_labels.account_id = ?", accountID). Group("conversation_labels.tag_id") if resolved { query = query.Where("conversations.resolved_at IS NOT NULL AND conversations.resolved_at >= ? AND conversations.resolved_at < ?", since, until) } else { query = query.Where("conversations.created_at >= ? AND conversations.created_at < ?", since, until) } if err := query.Scan(&rows).Error; err != nil { return err } for _, row := range rows { metric := result[row.ID] if resolved { metric.ResolvedCount = row.Count } else { metric.ConversationsCount = row.Count } result[row.ID] = metric } return nil } func loadLabelEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, businessHours bool) error { valueColumn := "reporting_events.value" if businessHours { valueColumn = "reporting_events.value_in_business_hours" } load := func(names []string, apply func(*reportCSVMetricSet, float64)) error { var rows []struct { ID uint Value float64 } err := db.WithContext(ctx).Model(&model.ReportingEvent{}). Select("conversation_labels.tag_id AS id, AVG("+valueColumn+") AS value"). Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = reporting_events.conversation_id"). Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until). Group("conversation_labels.tag_id").Scan(&rows).Error if err != nil { return err } for _, row := range rows { metric := result[row.ID] apply(&metric, row.Value) result[row.ID] = metric } return nil } if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil { return err } if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil { return err } return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value }) } func readableReportMetrics(metric reportCSVMetricSet) []string { return []string{ strconv.FormatInt(metric.ConversationsCount, 10), formatReportDuration(metric.AvgFirstResponse), formatReportDuration(metric.AvgResolution), formatReportDuration(metric.AvgReply), strconv.FormatInt(metric.ResolvedCount, 10), } } func formatReportDuration(seconds float64) string { value := int64(seconds) if value <= 0 { return "N/A" } units := []struct { Name string Seconds int64 }{{"day", 86400}, {"hour", 3600}, {"minute", 60}, {"second", 1}} parts := make([]string, 0, 2) remaining := value for _, unit := range units { count := remaining / unit.Seconds remaining %= unit.Seconds if count == 0 { continue } label := unit.Name if count != 1 { label += "s" } parts = append(parts, strconv.FormatInt(count, 10)+" "+label) if len(parts) == 2 { break } } if len(parts) == 0 { return "0 seconds" } return parts[0] + optionalSecondPart(parts) } func optionalSecondPart(parts []string) string { if len(parts) < 2 { return "" } return " " + parts[1] } func intString(value interface{}) string { switch typed := value.(type) { case int: return strconv.Itoa(typed) case int64: return strconv.FormatInt(typed, 10) case float64: return strconv.FormatInt(int64(typed), 10) default: return fmt.Sprintf("%v", value) } } func floatValue(value interface{}) float64 { switch typed := value.(type) { case float64: return typed case int64: return float64(typed) case int: return float64(typed) default: return 0 } }