package service import ( "context" "errors" "fmt" "sort" "strconv" "strings" "time" "github.com/gochat/gochat/internal/model" "gorm.io/gorm" ) type AnalyticsTimeseriesPoint struct { Value float64 `json:"value"` Timestamp int64 `json:"timestamp"` Count int64 `json:"count,omitempty"` } func (s *AnalyticsService) analyticsDB() (*gorm.DB, error) { if s == nil || s.db == nil { return nil, errors.New("analytics database is required") } return s.db, nil } func (s *AnalyticsService) EnsureRollupsForRange(ctx context.Context, accountID uint, since, until time.Time) error { if s == nil || s.rollups == nil || s.db == nil || since.IsZero() || until.IsZero() { return nil } start := dayStart(since) end := dayStart(until) if until.After(end) { end = end.AddDate(0, 0, 1) } for d := start; d.Before(end); d = d.AddDate(0, 0, 1) { var count int64 if err := s.db.WithContext(ctx).Model(&model.ReportingEventsRollup{}).Where("account_id = ? AND date = ?", accountID, d).Count(&count).Error; err != nil { return err } if count > 0 { continue } if err := s.rollups.ComputeDailyRollup(ctx, accountID, d); err != nil { return err } } return nil } func (s *AnalyticsService) GetTimeseries(ctx context.Context, accountID uint, metric string, since, until time.Time, reportType string, id uint, groupBy string, businessHours bool) ([]AnalyticsTimeseriesPoint, error) { if strings.TrimSpace(groupBy) == "" { groupBy = "day" } if reportType == "" { reportType = "account" } if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil { return nil, err } switch metric { case "conversations_count": return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, false) case "resolutions_count": return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, true) case "incoming_messages_count": return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, model.MessageTypeIncoming) case "outgoing_messages_count": return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, model.MessageTypeOutgoing) case "avg_first_response_time": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{model.MetricNameFirstResponse}, true, businessHours) case "avg_resolution_time": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours) case "reply_time": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{model.MetricNameReplyTime}, true, businessHours) case "bot_resolutions_count": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours) case "bot_handoffs_count": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours) default: return nil, fmt.Errorf("unsupported report metric %q", metric) } } func (s *AnalyticsService) liveConversationMetrics(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) { db, err := s.analyticsDB() if err != nil { return nil, err } base := func() *gorm.DB { q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID) if teamID > 0 { q = q.Where("team_id = ?", teamID) } return q } var result ConversationMetrics if err := base().Where("status = ?", string(model.ConversationStatusOpen)).Count(&result.OpenCount).Error; err != nil { return nil, err } if err := base().Where("status = ? AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)", string(model.ConversationStatusOpen)).Count(&result.UnattendedCount).Error; err != nil { return nil, err } if err := base().Where("status = ? AND assignee_id IS NULL", string(model.ConversationStatusOpen)).Count(&result.UnassignedCount).Error; err != nil { return nil, err } if err := base().Where("status = ?", string(model.ConversationStatusPending)).Count(&result.PendingCount).Error; err != nil { return nil, err } return &result, nil } func (s *AnalyticsService) conversationCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, resolved bool) ([]AnalyticsTimeseriesPoint, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var conversations []model.Conversation 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) if err := q.Find(&conversations).Error; err != nil { return nil, err } buckets := map[time.Time]*AnalyticsTimeseriesPoint{} for _, conversation := range conversations { t := conversation.CreatedAt if resolved && conversation.ResolvedAt != nil { t = *conversation.ResolvedAt } bucket := bucketStart(t, groupBy) point := ensureTimeseriesBucket(buckets, bucket) point.Value++ } return sortedTimeseries(buckets), nil } func (s *AnalyticsService) messageCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, messageType model.MessageType) ([]AnalyticsTimeseriesPoint, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var messages []model.Message 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) if err := q.Find(&messages).Error; err != nil { return nil, err } buckets := map[time.Time]*AnalyticsTimeseriesPoint{} for _, message := range messages { point := ensureTimeseriesBucket(buckets, bucketStart(message.CreatedAt, groupBy)) point.Value++ } return sortedTimeseries(buckets), nil } func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, names []string, average bool, businessHours bool) ([]AnalyticsTimeseriesPoint, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var events []model.ReportingEvent 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 err := q.Find(&events).Error; err != nil { return nil, err } buckets := map[time.Time]*AnalyticsTimeseriesPoint{} for _, event := range events { point := ensureTimeseriesBucket(buckets, bucketStart(event.CreatedAt, groupBy)) value := event.Value if businessHours { value = event.ValueInBusinessHours } if average { point.Value += value point.Count++ } else { point.Value++ } } if average { for _, point := range buckets { if point.Count > 0 { point.Value = point.Value / float64(point.Count) } } } return sortedTimeseries(buckets), nil } func applyConversationDimension(query *gorm.DB, reportType string, id uint) *gorm.DB { switch reportType { case "inbox": if id > 0 { query = query.Where("inbox_id = ?", id) } case "agent": if id > 0 { query = query.Where("assignee_id = ?", id) } case "team": if id > 0 { query = query.Where("team_id = ?", id) } case "label": if id > 0 { query = query.Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = conversations.id").Where("conversation_labels.tag_id = ?", id) } } return query } func applyMessageDimension(query *gorm.DB, reportType string, id uint) *gorm.DB { switch reportType { case "inbox": if id > 0 { query = query.Where("messages.inbox_id = ?", id) } case "agent": if id > 0 { query = query.Where("messages.sender_type = ? AND messages.sender_id = ?", "User", id) } case "team": if id > 0 { query = query.Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").Where("conversations.team_id = ?", id) } case "label": if id > 0 { query = query.Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = messages.conversation_id").Where("conversation_labels.tag_id = ?", id) } } return query } func applyEventDimension(query *gorm.DB, reportType string, id uint) *gorm.DB { switch reportType { case "inbox": if id > 0 { query = query.Where("inbox_id = ?", id) } case "agent": if id > 0 { query = query.Where("user_id = ?", id) } case "team": if id > 0 { query = query.Joins("INNER JOIN conversations ON conversations.id = reporting_events.conversation_id").Where("conversations.team_id = ?", id) } case "label": if id > 0 { query = query.Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = reporting_events.conversation_id").Where("conversation_labels.tag_id = ?", id) } } return query } func dayStart(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) } func bucketStart(t time.Time, groupBy string) time.Time { t = t.UTC() switch groupBy { case "hour": return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC) case "week": start := dayStart(t) return start.AddDate(0, 0, -int(start.Weekday())) case "month": return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) case "year": return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, time.UTC) default: return dayStart(t) } } func ensureTimeseriesBucket(buckets map[time.Time]*AnalyticsTimeseriesPoint, bucket time.Time) *AnalyticsTimeseriesPoint { point := buckets[bucket] if point == nil { point = &AnalyticsTimeseriesPoint{Timestamp: bucket.Unix()} buckets[bucket] = point } return point } func sortedTimeseries(buckets map[time.Time]*AnalyticsTimeseriesPoint) []AnalyticsTimeseriesPoint { keys := make([]time.Time, 0, len(buckets)) for key := range buckets { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { return keys[i].Before(keys[j]) }) result := make([]AnalyticsTimeseriesPoint, 0, len(keys)) for _, key := range keys { result = append(result, *buckets[key]) } return result } func parseReportDimensionID(raw string) uint { if strings.TrimSpace(raw) == "" { return 0 } parsed, err := strconv.ParseUint(raw, 10, 64) if err != nil { return 0 } return uint(parsed) } func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) { if groupBy != "team_id" && groupBy != "assignee_id" { return nil, errors.New("invalid group_by") } db, err := s.analyticsDB() if err != nil { return nil, err } type row struct { GroupID *uint Count int64 } load := func(where string) (map[uint]int64, error) { var rows []row if err := db.WithContext(ctx).Model(&model.Conversation{}). Select(groupBy+" AS group_id, COUNT(*) AS count"). Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen)). Group(groupBy). Scan(&rows).Error; err != nil { return nil, err } counts := map[uint]int64{} for _, row := range rows { if row.GroupID == nil { counts[0] += row.Count continue } counts[*row.GroupID] += row.Count } return counts, nil } open, err := load("") if err != nil { return nil, err } unattended, err := load("AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)") if err != nil { return nil, err } unassigned, err := load("AND assignee_id IS NULL") if err != nil { return nil, err } ids := make([]uint, 0, len(open)) for id := range open { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) result := make([]map[string]interface{}, 0, len(ids)) for _, id := range ids { metric := map[string]interface{}{ groupBy: id, "open": open[id], "unattended": unattended[id], "unassigned": unassigned[id], } if id == 0 { metric[groupBy] = nil } result = append(result, metric) } return result, nil } func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) { db, err := s.analyticsDB() 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 { return nil, err } return result, nil } func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string) (interface{}, error) { switch reportType { case "account": return s.liveConversationMetrics(ctx, accountID, 0) case "agent": return s.groupedLiveConversationMetrics(ctx, accountID, "assignee_id") case "team": return s.groupedLiveConversationMetrics(ctx, accountID, "team_id") default: return s.liveConversationMetrics(ctx, accountID, 0) } } func (s *AnalyticsService) conversationSummary(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) { db, err := s.analyticsDB() if err != nil { return nil, err } countConversations := func(where string, args ...interface{}) (int64, error) { var count int64 q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Where(where, args...) err := q.Count(&count).Error return count, err } countMessages := func(messageType model.MessageType) (int64, error) { var count int64 err := db.WithContext(ctx).Model(&model.Message{}). Where("account_id = ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, string(messageType), since, until). Count(&count).Error return count, err } conversations, err := countConversations("created_at >= ? AND created_at < ?", since, until) if err != nil { return nil, err } incoming, err := countMessages(model.MessageTypeIncoming) if err != nil { return nil, err } outgoing, err := countMessages(model.MessageTypeOutgoing) if err != nil { return nil, err } resolutions, err := countConversations("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until) if err != nil { return nil, err } firstResponse, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameFirstResponse}, since, until) resolutionTime, _ := s.averageEventValue(ctx, accountID, []string{"conversation_resolved", model.MetricNameResolutionTime}, since, until) replyTime, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameReplyTime}, since, until) return map[string]interface{}{ "conversations_count": conversations, "incoming_messages_count": incoming, "outgoing_messages_count": outgoing, "avg_first_response_time": firstResponse, "avg_resolution_time": resolutionTime, "resolutions_count": resolutions, "reply_time": replyTime, }, nil } func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var inboxIDs []uint if db.Migrator().HasTable(&model.AgentBotInbox{}) { if err := db.WithContext(ctx).Model(&model.AgentBotInbox{}). Where("status = ?", model.AgentBotInboxActive). Where("account_id = ? OR account_id IS NULL", accountID). Distinct("inbox_id").Pluck("inbox_id", &inboxIDs).Error; err != nil { return nil, err } } conversationCount := int64(0) messageCount := int64(0) if len(inboxIDs) > 0 { if err := db.WithContext(ctx).Model(&model.Conversation{}). Where("account_id = ? AND inbox_id IN ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, since, until). Count(&conversationCount).Error; err != nil { return nil, err } if err := db.WithContext(ctx).Model(&model.Message{}). Where("account_id = ? AND inbox_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, string(model.MessageTypeOutgoing), since, until). Count(&messageCount).Error; err != nil { return nil, err } } summary, err := s.botSummaryCounts(ctx, accountID, since, until) if err != nil { return nil, err } resolutionRate := 0 handoffRate := 0 if conversationCount > 0 { resolutionRate = int(float64(summary.BotResolutionsCount) / float64(conversationCount) * 100) handoffRate = int(float64(summary.BotHandoffsCount) / float64(conversationCount) * 100) } return map[string]interface{}{ "conversation_count": conversationCount, "message_count": messageCount, "resolution_rate": resolutionRate, "handoff_rate": handoffRate, }, nil } func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) (map[string]interface{}, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var inboxes []model.Inbox if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&inboxes).Error; err != nil { return nil, err } var tags []model.Tag if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&tags).Error; err != nil { return nil, err } type countRow struct { InboxID uint TagID uint Count int64 } var rows []countRow if len(inboxes) > 0 && len(tags) > 0 { if err := db.WithContext(ctx).Table("conversation_labels"). Select("conversations.inbox_id AS inbox_id, conversation_labels.tag_id AS tag_id, COUNT(*) AS count"). Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id"). Where("conversation_labels.account_id = ?", accountID). Group("conversations.inbox_id, conversation_labels.tag_id"). Scan(&rows).Error; err != nil { return nil, err } } counts := map[uint]map[uint]int64{} for _, row := range rows { if counts[row.InboxID] == nil { counts[row.InboxID] = map[uint]int64{} } counts[row.InboxID][row.TagID] = row.Count } inboxPayload := make([]map[string]interface{}, 0, len(inboxes)) for _, inbox := range inboxes { inboxPayload = append(inboxPayload, map[string]interface{}{"id": inbox.ID, "name": inbox.Name}) } labelPayload := make([]map[string]interface{}, 0, len(tags)) for _, tag := range tags { labelPayload = append(labelPayload, map[string]interface{}{"id": tag.ID, "title": tag.Name}) } matrix := make([][]int64, 0, len(inboxes)) for _, inbox := range inboxes { row := make([]int64, 0, len(tags)) for _, tag := range tags { row = append(row, counts[inbox.ID][tag.ID]) } matrix = append(matrix, row) } return map[string]interface{}{"inboxes": inboxPayload, "labels": labelPayload, "matrix": matrix}, nil } func (s *AnalyticsService) firstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (map[string]map[string]int64, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var events []model.ReportingEvent if err := db.WithContext(ctx).Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ?", accountID, model.MetricNameFirstResponse, since, until).Find(&events).Error; err != nil { return nil, err } inboxIDs := make([]uint, 0, len(events)) for _, event := range events { if event.InboxID != nil { inboxIDs = append(inboxIDs, *event.InboxID) } } var inboxes []model.Inbox if len(inboxIDs) > 0 { if err := db.WithContext(ctx).Where("account_id = ? AND id IN ?", accountID, inboxIDs).Find(&inboxes).Error; err != nil { return nil, err } } channelTypes := map[uint]string{} for _, inbox := range inboxes { channelTypes[inbox.ID] = inbox.ChannelType } result := map[string]map[string]int64{} for _, event := range events { if event.InboxID == nil { continue } channelType := channelTypes[*event.InboxID] if channelType == "" { continue } if result[channelType] == nil { result[channelType] = map[string]int64{"0-1h": 0, "1-4h": 0, "4-8h": 0, "8-24h": 0, "24h+": 0} } switch { case event.Value < 3600: result[channelType]["0-1h"]++ case event.Value < 14400: result[channelType]["1-4h"]++ case event.Value < 28800: result[channelType]["4-8h"]++ case event.Value < 86400: result[channelType]["8-24h"]++ default: result[channelType]["24h+"]++ } } return result, nil } func (s *AnalyticsService) outgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time, groupBy string) ([]map[string]interface{}, error) { if groupBy == "" { groupBy = "agent" } db, err := s.analyticsDB() if err != nil { return nil, err } switch groupBy { case "agent": return s.outgoingMessagesByAgent(ctx, db, accountID, since, until) case "team": return s.outgoingMessagesByConversationField(ctx, db, accountID, since, until, "team_id", "teams") case "inbox": return s.outgoingMessagesByInbox(ctx, db, accountID, since, until) case "label": return s.outgoingMessagesByLabel(ctx, db, accountID, since, until) default: return nil, errors.New("invalid group_by") } } func (s *AnalyticsService) averageEventValue(ctx context.Context, accountID uint, names []string, since, until time.Time) (float64, error) { db, err := s.analyticsDB() if err != nil { return 0, err } var row struct { Value float64 Count int64 } err = db.WithContext(ctx).Model(&model.ReportingEvent{}). Select("COALESCE(SUM(value), 0) AS value, COUNT(*) AS count"). Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until). Scan(&row).Error if err != nil || row.Count == 0 { return 0, err } return row.Value / float64(row.Count), nil } type outgoingCountRow struct { ID uint Name string Count int64 } func (s *AnalyticsService) outgoingMessagesByAgent(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) { var rows []outgoingCountRow err := db.WithContext(ctx).Table("messages"). Select("messages.sender_id AS id, users.name AS name, COUNT(*) AS count"). Joins("LEFT JOIN users ON users.id = messages.sender_id"). Where("messages.account_id = ? AND messages.message_type = ? AND messages.sender_type = ? AND messages.sender_id IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), "User", since, until). Group("messages.sender_id, users.name").Scan(&rows).Error return outgoingRows(rows, "agent"), err } func (s *AnalyticsService) outgoingMessagesByConversationField(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time, field, table string) ([]map[string]interface{}, error) { var rows []outgoingCountRow err := db.WithContext(ctx).Table("messages"). Select("conversations."+field+" AS id, "+table+".name AS name, COUNT(*) AS count"). Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id"). Joins("LEFT JOIN "+table+" ON "+table+".id = conversations."+field). Where("messages.account_id = ? AND messages.message_type = ? AND conversations."+field+" IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until). Group("conversations." + field + ", " + table + ".name").Scan(&rows).Error return outgoingRows(rows, strings.TrimSuffix(field, "_id")), err } func (s *AnalyticsService) outgoingMessagesByInbox(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) { var rows []outgoingCountRow err := db.WithContext(ctx).Table("messages"). Select("messages.inbox_id AS id, inboxes.name AS name, COUNT(*) AS count"). Joins("LEFT JOIN inboxes ON inboxes.id = messages.inbox_id"). Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until). Group("messages.inbox_id, inboxes.name").Scan(&rows).Error return outgoingRows(rows, "inbox"), err } func (s *AnalyticsService) outgoingMessagesByLabel(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) { var rows []outgoingCountRow err := db.WithContext(ctx).Table("messages"). Select("tags.id AS id, tags.name AS name, COUNT(*) AS count"). Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id"). Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = conversations.id"). Joins("INNER JOIN tags ON tags.id = conversation_labels.tag_id"). Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until). Group("tags.id, tags.name").Scan(&rows).Error return outgoingRows(rows, "label"), err } func outgoingRows(rows []outgoingCountRow, _ string) []map[string]interface{} { result := make([]map[string]interface{}, 0, len(rows)) for _, row := range rows { result = append(result, map[string]interface{}{"id": row.ID, "name": row.Name, "outgoing_messages_count": row.Count}) } return result }