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, timezoneOffset float64, businessHours bool) ([]AnalyticsTimeseriesPoint, error) { if strings.TrimSpace(groupBy) == "" { groupBy = "day" } if reportType == "" { reportType = "account" } loc := timeseriesLocation(timezoneOffset) 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, loc, false) case "resolutions_count": return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, true) case "incoming_messages_count": return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, model.MessageTypeIncoming) case "outgoing_messages_count": return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, model.MessageTypeOutgoing) case "avg_first_response_time": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameFirstResponse}, true, businessHours, "") case "avg_resolution_time": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours, "") case "reply_time": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameReplyTime}, true, businessHours, "") case "bot_resolutions_count": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours, "exclude_bot_handoffs") case "bot_handoffs_count": return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours, "distinct_conversation") 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, loc *time.Location, 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 := seedTimeseriesBuckets(since, until, groupBy, loc) for _, conversation := range conversations { t := conversation.CreatedAt if resolved && conversation.ResolvedAt != nil { t = *conversation.ResolvedAt } bucket := bucketStart(t, groupBy, loc) 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, loc *time.Location, 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 := seedTimeseriesBuckets(since, until, groupBy, loc) for _, message := range messages { point := ensureTimeseriesBucket(buckets, bucketStart(message.CreatedAt, groupBy, loc)) 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, loc *time.Location, names []string, average bool, businessHours bool, countStrategy string) ([]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 !average && countStrategy == "exclude_bot_handoffs" { var handoffIDs []uint handoffQ := 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) handoffQ = applyEventDimension(handoffQ, reportType, id) if err := handoffQ.Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil { return nil, err } if len(handoffIDs) > 0 { q = q.Where("conversation_id NOT IN ?", handoffIDs) } } if err := q.Find(&events).Error; err != nil { return nil, err } buckets := seedTimeseriesBuckets(since, until, groupBy, loc) distinctConversationsByBucket := map[time.Time]map[uint]struct{}{} for _, event := range events { bucket := bucketStart(event.CreatedAt, groupBy, loc) point := ensureTimeseriesBucket(buckets, bucket) value := event.Value if businessHours { value = event.ValueInBusinessHours } if average { point.Value += value point.Count++ } else if countStrategy == "distinct_conversation" { if event.ConversationID == nil { continue } if distinctConversationsByBucket[bucket] == nil { distinctConversationsByBucket[bucket] = map[uint]struct{}{} } if _, exists := distinctConversationsByBucket[bucket][*event.ConversationID]; exists { continue } distinctConversationsByBucket[bucket][*event.ConversationID] = struct{}{} point.Value++ } 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, loc *time.Location) time.Time { if loc == nil { loc = time.UTC } t = t.In(loc) switch groupBy { case "hour": return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, loc) case "week": start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) return start.AddDate(0, 0, -int(start.Weekday())) case "month": return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, loc) case "year": return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, loc) default: return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) } } func timeseriesLocation(offsetHours float64) *time.Location { return time.FixedZone("report", int(offsetHours*3600)) } func seedTimeseriesBuckets(since, until time.Time, groupBy string, loc *time.Location) map[time.Time]*AnalyticsTimeseriesPoint { buckets := map[time.Time]*AnalyticsTimeseriesPoint{} if since.IsZero() || until.IsZero() || !since.Before(until) { return buckets } for bucket := bucketStart(since, groupBy, loc); bucket.Before(until.In(loc)); bucket = nextBucketStart(bucket, groupBy) { ensureTimeseriesBucket(buckets, bucket) } return buckets } func nextBucketStart(bucket time.Time, groupBy string) time.Time { switch groupBy { case "hour": return bucket.Add(time.Hour) case "week": return bucket.AddDate(0, 0, 7) case "month": return bucket.AddDate(0, 1, 0) case "year": return bucket.AddDate(1, 0, 0) default: return bucket.AddDate(0, 0, 1) } } 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, teamID uint) ([]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 q := db.WithContext(ctx).Model(&model.Conversation{}). Select(groupBy+" AS group_id, COUNT(*) AS count"). Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen)) if teamID > 0 { q = q.Where("team_id = ?", teamID) } if err := q.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, reportType string, id uint) (*BotSummaryResponse, error) { result := &BotSummaryResponse{} resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, "exclude_bot_handoffs") if err != nil { return nil, err } handoffs, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, "distinct_conversation") if err != nil { return nil, err } result.BotResolutionsCount = resolutions result.BotHandoffsCount = handoffs return result, nil } func (s *AnalyticsService) reportSummaryCounts(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, businessHours bool) (*ReportSummaryResponse, error) { conversations, err := s.aggregateConversationCount(ctx, accountID, since, until, reportType, id, false) if err != nil { return nil, err } incoming, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeIncoming) if err != nil { return nil, err } outgoing, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeOutgoing) if err != nil { return nil, err } resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, "") if err != nil { return nil, err } firstResponse, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameFirstResponse}, businessHours) if err != nil { return nil, err } resolutionTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, businessHours) if err != nil { return nil, err } replyTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameReplyTime}, businessHours) if err != nil { return nil, err } return &ReportSummaryResponse{ ConversationsCount: conversations, IncomingMessagesCount: incoming, OutgoingMessagesCount: outgoing, AvgFirstResponseTime: firstResponse, AvgResolutionTime: resolutionTime, ResolutionsCount: resolutions, ReplyTime: replyTime, }, nil } func (s *AnalyticsService) aggregateConversationCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, resolved bool) (int64, error) { db, err := s.analyticsDB() if err != nil { return 0, err } 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) var count int64 if err := q.Count(&count).Error; err != nil { return 0, err } return count, nil } func (s *AnalyticsService) aggregateMessageCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, messageType model.MessageType) (int64, error) { db, err := s.analyticsDB() if err != nil { return 0, err } 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) var count int64 if err := q.Count(&count).Error; err != nil { return 0, err } return count, nil } func (s *AnalyticsService) aggregateEventAverage(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, businessHours bool) (float64, error) { db, err := s.analyticsDB() if err != nil { return 0, err } valueColumn := "value" if businessHours { valueColumn = "value_in_business_hours" } var row struct { Value float64 Count int64 } q := db.WithContext(ctx).Model(&model.ReportingEvent{}). Select("COALESCE(SUM("+valueColumn+"), 0) AS value, COUNT(*) AS count"). Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until) q = applyEventDimension(q, reportType, id) if err := q.Scan(&row).Error; err != nil { return 0, err } if row.Count == 0 { return 0, nil } return row.Value / float64(row.Count), nil } func (s *AnalyticsService) aggregateEventCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, countStrategy string) (int64, error) { db, err := s.analyticsDB() if err != nil { return 0, err } 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 countStrategy == "exclude_bot_handoffs" { var handoffIDs []uint handoffQ := 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) handoffQ = applyEventDimension(handoffQ, reportType, id) if err := handoffQ.Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil { return 0, err } if len(handoffIDs) > 0 { q = q.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs) } } var count int64 if countStrategy == "distinct_conversation" { if err := q.Where("conversation_id IS NOT NULL").Distinct("conversation_id").Count(&count).Error; err != nil { return 0, err } return count, nil } if err := q.Count(&count).Error; err != nil { return 0, err } return count, nil } func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string, page int) (interface{}, error) { if reportType == "account" { return s.liveConversationMetrics(ctx, accountID, 0) } return s.agentConversationMetrics(ctx, accountID, page) } func (s *AnalyticsService) agentConversationMetrics(ctx context.Context, accountID uint, page int) ([]ReportAgentConversationMetric, error) { db, err := s.analyticsDB() if err != nil { return nil, err } if page < 1 { page = 1 } const perPage = 25 var accountUsers []model.AccountUser if err := db.WithContext(ctx). Preload("User"). Where("account_id = ?", accountID). Order("id ASC"). Limit(perPage). Offset((page - 1) * perPage). Find(&accountUsers).Error; err != nil { return nil, err } result := make([]ReportAgentConversationMetric, 0, len(accountUsers)) for _, accountUser := range accountUsers { metric, metricErr := s.liveConversationMetricsForAssignee(ctx, accountID, accountUser.UserID) if metricErr != nil { return nil, metricErr } result = append(result, ReportAgentConversationMetric{ ID: accountUser.User.ID, Name: accountUser.User.Name, Email: accountUser.User.Email, Thumbnail: accountUser.User.AvatarURL, Availability: accountUser.Availability, Metric: metric, }) } sort.SliceStable(result, func(i, j int) bool { return result[i].Metric["open"] > result[j].Metric["open"] }) return result, nil } func (s *AnalyticsService) liveConversationMetricsForAssignee(ctx context.Context, accountID uint, assigneeID uint) (map[string]int64, error) { db, err := s.analyticsDB() if err != nil { return nil, err } base := func() *gorm.DB { return db.WithContext(ctx).Model(&model.Conversation{}). Where("account_id = ? AND assignee_id = ? AND status = ?", accountID, assigneeID, string(model.ConversationStatusOpen)) } var openCount int64 var unattendedCount int64 if err := base().Count(&openCount).Error; err != nil { return nil, err } if err := base().Where("first_reply_created_at IS NULL OR waiting_since IS NOT NULL").Count(&unattendedCount).Error; err != nil { return nil, err } return map[string]int64{"open": openCount, "unattended": unattendedCount}, nil } 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 } } conversationIDs := []uint{} 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). Pluck("id", &conversationIDs).Error; err != nil { return nil, err } if len(conversationIDs) > 0 { if err := db.WithContext(ctx).Model(&model.Message{}). Where("account_id = ? AND conversation_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, conversationIDs, string(model.MessageTypeOutgoing), since, until). Count(&messageCount).Error; err != nil { return nil, err } } } conversationCount := int64(len(conversationIDs)) resolutions, handoffs, err := s.botMetricDistinctCounts(ctx, db, accountID, since, until) if err != nil { return nil, err } resolutionRate := 0 handoffRate := 0 if conversationCount > 0 { resolutionRate = int(float64(resolutions) / float64(conversationCount) * 100) handoffRate = int(float64(handoffs) / float64(conversationCount) * 100) } return map[string]interface{}{ "conversation_count": conversationCount, "message_count": messageCount, "resolution_rate": resolutionRate, "handoff_rate": handoffRate, }, nil } func (s *AnalyticsService) botMetricDistinctCounts(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) (int64, int64, error) { handoffIDs := []uint{} if err := db.WithContext(ctx).Model(&model.ReportingEvent{}). Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, "conversation_bot_handoff", since, until). Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil { return 0, 0, err } resolutionQ := db.WithContext(ctx).Model(&model.ReportingEvent{}). Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, "conversation_bot_resolved", since, until) if len(handoffIDs) > 0 { resolutionQ = resolutionQ.Where("conversation_id NOT IN ?", handoffIDs) } var resolutions int64 if err := resolutionQ.Distinct("conversation_id").Count(&resolutions).Error; err != nil { return 0, 0, err } return resolutions, int64(len(handoffIDs)), nil } func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint, filter InboxLabelMatrixFilter) (map[string]interface{}, error) { db, err := s.analyticsDB() if err != nil { return nil, err } var inboxes []model.Inbox inboxQ := db.WithContext(ctx).Where("account_id = ?", accountID) if len(filter.InboxIDs) > 0 { inboxQ = inboxQ.Where("id IN ?", filter.InboxIDs) } if err := inboxQ.Order("name ASC").Find(&inboxes).Error; err != nil { return nil, err } var tags []model.Tag tagQ := db.WithContext(ctx).Where("account_id = ?", accountID) if len(filter.LabelIDs) > 0 { tagQ = tagQ.Where("id IN ?", filter.LabelIDs) } if err := tagQ.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 { inboxIDs := make([]uint, 0, len(inboxes)) for _, inbox := range inboxes { inboxIDs = append(inboxIDs, inbox.ID) } labelIDs := make([]uint, 0, len(tags)) for _, tag := range tags { labelIDs = append(labelIDs, tag.ID) } countsQ := 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 = ? AND conversations.account_id = ?", accountID, accountID). Where("conversations.inbox_id IN ? AND conversation_labels.tag_id IN ?", inboxIDs, labelIDs) if !filter.Since.IsZero() && !filter.Until.IsZero() { countsQ = countsQ.Where("conversations.created_at >= ? AND conversations.created_at < ?", filter.Since, filter.Until) } if err := countsQ.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 eventQ := db.WithContext(ctx).Where("account_id = ? AND name = ?", accountID, model.MetricNameFirstResponse) if !since.IsZero() && !until.IsZero() { eventQ = eventQ.Where("created_at >= ? AND created_at < ?", since, until) } if err := eventQ.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) { type labelRow struct { Name string Count int64 } var rows []labelRow err := db.WithContext(ctx).Table("messages"). Select("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.name").Scan(&rows).Error if err != nil { return nil, err } names := make([]string, 0, len(rows)) for _, row := range rows { names = append(names, row.Name) } var labels []model.Tag if len(names) > 0 { if err := db.WithContext(ctx).Where("account_id = ? AND name IN ?", accountID, names).Find(&labels).Error; err != nil { return nil, err } } labelIDs := map[string]uint{} for _, label := range labels { labelIDs[label.Name] = label.ID } result := make([]map[string]interface{}, 0, len(rows)) for _, row := range rows { var id interface{} if labelID, ok := labelIDs[row.Name]; ok { id = labelID } result = append(result, map[string]interface{}{"id": id, "name": row.Name, "outgoing_messages_count": row.Count}) } return result, nil } 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 }